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
535func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) {
536 next := args
537 next.Provider = providerName
538 next.Model = model
539 resolved, err := Resolve(next, true)
540 if err != nil {
541 return nil, "", "", err
542 }
543 resolved.UseSandbox(base.Sandbox)
544 if extMgr != nil {
545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
546 }
547 if prepareRegistry != nil {
548 prepareRegistry(resolved.ToolRegistry)
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 ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
574 // A newly created meta-only session is already on the active pair. Avoid
575 // reopening and closing it through the resume path: Close intentionally
576 // removes fresh empty sessions, and a second append handle would otherwise
577 // keep an unlinked file alive.
578 if len(ag.Messages()) == 0 &&
579 strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
580 strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
581 return candidate, nil
582 }
583 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
584 if err != nil {
585 return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
586 }
587 if !candidate.rebuilt {
588 candidate.agent.SetMessages(candidate.messages)
589 candidate.agent.SeedCost(candidate.cumulative)
590 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
591 }
592 if closeErr := sess.Close(); closeErr != nil {
593 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
594 }
595 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
596 return candidate, nil
597}
598
599// applyInitialSessionResume applies the same provider/model-aware candidate
600// contract used by interactive session switching to non-interactive startup
601// modes. The returned session owns the replacement append handle.
602func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
603 return applyInitialSessionResumeWithRegistry(ctx, args, base, extMgr, sess, ag, nil)
604}
605
606func applyInitialSessionResumeWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, prepareRegistry func(core.Registry) core.Registry) (*core.Session, *core.Agent, string, string, error) {
607 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
608 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
609 return buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
610 })
611 if err != nil {
612 return nil, ag, "", "", err
613 }
614 return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
615}
616
617func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, runtime *subagentRuntime) (*core.Session, *core.Agent, string, string, error) {
618 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
619 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
620 next := args
621 next.Provider = providerName
622 next.Model = model
623 resolved, resolveErr := Resolve(next, true)
624 if resolveErr != nil {
625 return nil, "", "", resolveErr
626 }
627 resolved.UseSandbox(base.Sandbox)
628 if extMgr != nil {
629 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
630 }
631 if runtime != nil {
632 runtime.SetProvider(resolved.Provider)
633 runtime.SetModel(resolved.Model)
634 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
635 runtime.SetFastMode(resolved.FastMode)
636 runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
637 }
638 rebuilt := resolved.NewAgent()
639 wireNonInteractiveAgentExtHooks(ctx, rebuilt, extMgr)
640 return rebuilt, resolved.Provider, resolved.Model, nil
641 })
642 if err != nil {
643 return nil, ag, "", "", err
644 }
645 return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
646}
647
648// fanoutAgentEvent translates a core.AgentEvent into the wire-format
649// EventFromHost and pushes it through the extension manager. Only
650// the events that have a clear extension-facing meaning are
651// forwarded; internal-only ones (text_delta, tool_progress) are
652// dropped to keep the per-extension stream sane.
653func sessionContext(sess *core.Session) *extproto.SessionContext {
654 if sess == nil {
655 return nil
656 }
657 return &extproto.SessionContext{
658 ID: sess.Meta.ID,
659 ParentID: sess.Meta.Parent,
660 Path: sess.Path,
661 CWD: sess.Meta.CWD,
662 ForkPoint: sess.Meta.ForkPoint,
663 }
664}
665
666func announceSession(mgr *extensions.Manager, sess *core.Session) {
667 if mgr == nil {
668 return
669 }
670 var states map[string]json.RawMessage
671 if sess != nil {
672 states = copyExtensionStates(sess.ExtensionState)
673 }
674 mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
675 if sess != nil {
676 mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
677 }
678}
679
680func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
681 if len(states) == 0 {
682 return nil
683 }
684 out := make(map[string]json.RawMessage, len(states))
685 for name, state := range states {
686 out[name] = append(json.RawMessage(nil), state...)
687 }
688 return out
689}
690
691func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {
692 if sess == nil {
693 return nil
694 }
695 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
696 if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
697 return fmt.Errorf("extension state: %w", err)
698 }
699 if mgr != nil {
700 mgr.UpdateSessionState(details.Extension, details.State)
701 }
702 }
703 if err := persistGoalToolResult(sess, result); err != nil {
704 return fmt.Errorf("goal state: %w", err)
705 }
706 return nil
707}
708
709func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710 if sess == nil {
711 return nil
712 }
713 update, ok := tools.GoalUpdateFromResult(result)
714 if !ok {
715 return nil
716 }
717 if update.Status == core.GoalActive {
718 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
719 return fmt.Errorf("cannot replace an active goal")
720 }
721 if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
722 return fmt.Errorf("goal update does not belong to the active mission")
723 }
724 return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager})
725 }
726 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
727 return nil
728 }
729 goal := *sess.Meta.Goal
730 goal.Status = update.Status
731 goal.Reason = update.Reason
732 return sess.UpdateGoal(&goal)
733}
734
735func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
736 if mgr == nil {
737 return
738 }
739 switch e := ev.(type) {
740 case core.EvTurnStart:
741 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
742 case core.EvToolCall:
743 mgr.EmitEvent(extproto.EventFromHost{
744 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
745 })
746 case core.EvAssistantMessage:
747 // Concat the visible text portions of the message; binary
748 // blocks (tool_use, etc.) are skipped because subscribers
749 // usually want a string they can grep / display.
750 var text string
751 for _, c := range e.Message.Content {
752 if tb, ok := c.(provider.TextBlock); ok {
753 text += tb.Text
754 }
755 }
756 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
757 case core.EvTurnEnd:
758 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
759 if e.Err != nil {
760 ev.Error = e.Err.Error()
761 }
762 mgr.EmitEvent(ev)
763 }
764}
765
766// Run is the top-level entrypoint for the zut binary.
767func Run(rawArgs []string, version string) error {
768 // Apply network configuration before any subcommand can make an HTTP
769 // request. Standard proxy environment variables retain precedence.
770 applyConfiguredHTTPProxy()
771
772 // Extension installs can invoke external build and clone processes.
773 // Give those processes a cancellation context without changing signal
774 // handling for the interactive modes below.
775 extCtx := context.Background()
776 stopExtContext := func() {}
777 if len(rawArgs) > 0 && rawArgs[0] == "ext" {
778 extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
779 }
780 defer stopExtContext()
781
782 // Subcommand router: `zut bot ...` is handled separately so the
783 // generic flag parser doesn't reject "bot" as a positional arg.
784 if handled, err := runBotCommand(rawArgs, version); handled {
785 return err
786 }
787 if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
788 return err
789 }
790 if handled, err := runUpdateCommand(rawArgs, version); handled {
791 return err
792 }
793 if handled, err := runZutfileCommand(rawArgs, version); handled {
794 return err
795 }
796 return runWithArgsRaw(rawArgs, version)
797}
798
799func runWithArgsRaw(rawArgs []string, version string) error {
800 // `zut rpc` is shorthand for `zut --rpc` so third-party apps can
801 // spawn the binary with a clean argv. Strip the leading 'rpc'
802 // token and let the rest flow through the normal arg parser.
803 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
805 }
806
807 if fi, err := os.Stdin.Stat(); err == nil {
808 rawArgs = argsForStdin(rawArgs, fi.Mode())
809 }
810 args, err := ParseArgs(rawArgs)
811 if err != nil {
812 PrintHelp(version)
813 return err
814 }
815 if args.Help {
816 PrintHelp(version)
817 return nil
818 }
819 if args.Version {
820 fmt.Println("zut", version)
821 return nil
822 }
823 // Reject invalid orchestration combinations before catalog repair or model
824 // refresh can mutate local state or launch background work.
825 if err := validateOrchestrationArgs(args); err != nil {
826 return err
827 }
828 // Model catalog: load any cached discovery data before we inspect
829 // the model list (list-models, print/json, interactive).
830 prepareRuntimeCatalog()
831
832 if args.ListModels {
833 printModels()
834 return nil
835 }
836
837 return runWithArgs(args, version)
838}
839
840func runWithArgs(args Args, version string) error {
841 if err := validateOrchestrationArgs(args); err != nil {
842 return err
843 }
844 ctx := context.Background()
845 switch args.Mode {
846 case ModePrint:
847 return runPrintMode(ctx, args, version)
848 case ModeStream:
849 return runStreamMode(ctx, args, version)
850 case ModeJSON:
851 return runJSONMode(ctx, args, version)
852 case ModeRPC:
853 return runRPCMode(ctx, args, version)
854 case ModeSubagentWorker:
855 return runSubagentWorkerMode(ctx, args, version)
856 default:
857 return runInteractive(ctx, args, version)
858 }
859}
860
861// ---- print / json modes: require credentials, run single-shot ----
862
863// nonInteractiveExtHooks is the HostHooks impl used by print / json
864// modes. They have no TUI, so notify / display go to stderr and
865// submit / insert are no-ops (the extension can't steer a
866// single-shot run once it's in flight anyway).
867type nonInteractiveExtHooks struct{}
868
869func (nonInteractiveExtHooks) Notify(ext, level, message string) {
870 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
871}
872func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
873func (nonInteractiveExtHooks) Submit(string) {}
874func (nonInteractiveExtHooks) SubmitSlash(string) {}
875func (nonInteractiveExtHooks) Insert(string) {}
876func (nonInteractiveExtHooks) Display(string, string) {}
877func (nonInteractiveExtHooks) ClearNotes(string) {}
878func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
879func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
880func (nonInteractiveExtHooks) ClosePanel(string, string) {}
881func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
882func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
883func (nonInteractiveExtHooks) ClearWidget(string, string) {}
884
885// setupNonInteractiveExtensions loads --ext paths and (unless
886// --no-ext) runs discovery. Returns the manager so the caller can
887// wire tools into the resolved registry, and a cleanup closure to
888// defer. Mirrors the interactive-mode setup minus the TUI hooks.
889func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
890 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
891 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
892 fmt.Fprintln(os.Stderr, "extension load:", e)
893 }
894 if !args.NoExt {
895 for _, e := range extMgr.Discover(ctx) {
896 fmt.Fprintln(os.Stderr, "extension load:", e)
897 }
898 }
899 extMgr.WaitForReady(3 * time.Second)
900 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
901 return extMgr, func() { extMgr.Stop(2 * time.Second) }
902}
903
904// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
905// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
906// interactive path wires up, so extensions get their normal
907// event-intercept surface in print / json / rpc flows too.
908func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
909 if ag == nil || extMgr == nil {
910 return
911 }
912 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
913 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
914 if res.Block {
915 return false, res.Reason, nil
916 }
917 return true, "", res.ModifiedArgs
918 }
919 ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
920 res := extMgr.InterceptTurnStart(turnCtx, step)
921 return !res.Block, res.Reason, res.Context
922 }
923 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
924 res := extMgr.InterceptAssistantMessage(ctx, text)
925 if res.Block {
926 return false, res.Reason, ""
927 }
928 return true, "", res.ReplaceText
929 }
930 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
931}
932
933type printStats struct {
934 Provider string `json:"provider"`
935 Model string `json:"model"`
936 PromptTokens int `json:"prompt_tokens"`
937 ReasoningTokens *int `json:"reasoning_tokens"`
938 GeneratedOutputTokens int `json:"generated_output_tokens"`
939 ElapsedMS int64 `json:"elapsed_ms"`
940}
941
942func writePrintStats(path, providerID, model string, usage provider.Usage, elapsed time.Duration) error {
943 var reasoning *int
944 generated := usage.OutputTokens
945 if usage.ReasoningTokensKnown {
946 count := usage.ReasoningTokens
947 reasoning = &count
948 generated -= count
949 if generated < 0 {
950 generated = 0
951 }
952 }
953 stats := printStats{
954 Provider: providerID,
955 Model: model,
956 PromptTokens: usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens,
957 ReasoningTokens: reasoning,
958 GeneratedOutputTokens: generated,
959 ElapsedMS: elapsed.Milliseconds(),
960 }
961 data, err := json.MarshalIndent(stats, "", " ")
962 if err != nil {
963 return fmt.Errorf("encode stats: %w", err)
964 }
965 data = append(data, '\n')
966 if err := os.WriteFile(path, data, 0o644); err != nil {
967 return fmt.Errorf("write stats %q: %w", path, err)
968 }
969 return nil
970}
971
972func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
973 if args.Orchestrate {
974 return runOrchestratedPrintMode(ctx, args, version)
975 }
976 if args.NoYolo {
977 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
978 }
979 r, err := Resolve(args, true)
980 if err != nil {
981 return err
982 }
983 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
984 defer stopExt()
985
986 ag := r.NewAgent()
987 initialAg := ag
988 defer func() {
989 closeAgentLSP(ag)
990 if ag != initialAg {
991 closeAgentLSP(initialAg)
992 }
993 }()
994 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
995 sess, err := openOrCreateSession(ctx, args, r, ag, version)
996 if err != nil {
997 return err
998 }
999 if sess != nil {
1000 var providerName, model string
1001 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1002 if err != nil {
1003 return err
1004 }
1005 r.Provider, r.Model = providerName, model
1006 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1007 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1008 }
1009 announceSession(extMgr, sess)
1010 wireRetryLifecyclePersistence(ag, sess)
1011 if sess != nil {
1012 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1013 }
1014
1015 piped, _ := readAllStdin()
1016 prompt := combinePromptInput(piped, args.Prompt)
1017 if prompt == "" {
1018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
1019 }
1020
1021 start := len(ag.Messages())
1022 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
1023 return err
1024 }
1025 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1026 return err
1027 }
1028 var persistCompaction func([]provider.Message) error
1029 if sess != nil {
1030 persistCompaction = sess.AppendCompaction
1031 }
1032 started := time.Now()
1033 usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1034 elapsed := time.Since(started)
1035 transcriptStart := start
1036 if recovery.Compacted {
1037 transcriptStart = recovery.OutputStart
1038 }
1039 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1040 if err != nil {
1041 return errors.Join(err, persistErr)
1042 }
1043 return persistErr
1044 }
1045 if err != nil {
1046 return err
1047 }
1048 if args.StatsPath != "" {
1049 return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
1050 }
1051 return nil
1052}
1053
1054func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1055 if args.Orchestrate {
1056 return runOrchestratedStreamMode(ctx, args, version)
1057 }
1058 if args.NoYolo {
1059 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
1060 }
1061 r, err := Resolve(args, true)
1062 if err != nil {
1063 return err
1064 }
1065 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1066 defer stopExt()
1067
1068 ag := r.NewAgent()
1069 initialAg := ag
1070 defer func() {
1071 closeAgentLSP(ag)
1072 if ag != initialAg {
1073 closeAgentLSP(initialAg)
1074 }
1075 }()
1076 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1077 sess, err := openOrCreateSession(ctx, args, r, ag, version)
1078 if err != nil {
1079 return err
1080 }
1081 if sess != nil {
1082 var providerName, model string
1083 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1084 if err != nil {
1085 return err
1086 }
1087 r.Provider, r.Model = providerName, model
1088 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1089 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1090 }
1091 announceSession(extMgr, sess)
1092 wireRetryLifecyclePersistence(ag, sess)
1093 if sess != nil {
1094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1095 }
1096
1097 piped, _ := readAllStdin()
1098 prompt := combinePromptInput(piped, args.Prompt)
1099 if prompt == "" {
1100 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1101 }
1102
1103 start := len(ag.Messages())
1104 preSink, finishPre := newStreamTextSink(os.Stdout)
1105 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1106 finishPre()
1107 return err
1108 }
1109 finishPre()
1110 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1111 return err
1112 }
1113 var persistCompaction func([]provider.Message) error
1114 if sess != nil {
1115 persistCompaction = sess.AppendCompaction
1116 }
1117 recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1118 transcriptStart := start
1119 if recovery.Compacted {
1120 transcriptStart = recovery.OutputStart
1121 }
1122 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1123 if err != nil {
1124 return errors.Join(err, persistErr)
1125 }
1126 return persistErr
1127 }
1128 return err
1129}
1130
1131func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1132 var streamed, wroteText, lastWasNL bool
1133 writeText := func(text string) {
1134 if text == "" {
1135 return
1136 }
1137 _, _ = fmt.Fprint(out, text)
1138 if syncer, ok := out.(interface{ Sync() error }); ok {
1139 _ = syncer.Sync()
1140 }
1141 wroteText = true
1142 lastWasNL = strings.HasSuffix(text, "\n")
1143 }
1144 sink := func(ev core.AgentEvent) {
1145 switch e := ev.(type) {
1146 case core.EvAssistantStart:
1147 streamed = false
1148 case core.EvTextDelta:
1149 streamed = true
1150 writeText(e.Delta)
1151 case core.EvAssistantMessage:
1152 if streamed {
1153 return
1154 }
1155 var text strings.Builder
1156 for _, content := range e.Message.Content {
1157 if block, ok := content.(provider.TextBlock); ok {
1158 if text.Len() > 0 {
1159 text.WriteString("\n")
1160 }
1161 text.WriteString(block.Text)
1162 }
1163 }
1164 writeText(text.String())
1165 }
1166 }
1167 finish := func() {
1168 if wroteText && !lastWasNL {
1169 writeText("\n")
1170 }
1171 }
1172 return sink, finish
1173}
1174
1175func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1176 if args.Orchestrate {
1177 return runOrchestratedJSONMode(ctx, args, version)
1178 }
1179 if args.NoYolo {
1180 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1181 }
1182 r, err := Resolve(args, true)
1183 if err != nil {
1184 return err
1185 }
1186 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1187 defer stopExt()
1188
1189 ag := r.NewAgent()
1190 initialAg := ag
1191 defer func() {
1192 closeAgentLSP(ag)
1193 if ag != initialAg {
1194 closeAgentLSP(initialAg)
1195 }
1196 }()
1197 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1198 sess, err := openOrCreateSession(ctx, args, r, ag, version)
1199 if err != nil {
1200 return err
1201 }
1202 if sess != nil {
1203 var providerName, model string
1204 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1205 if err != nil {
1206 return err
1207 }
1208 r.Provider, r.Model = providerName, model
1209 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1210 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1211 }
1212 announceSession(extMgr, sess)
1213 wireRetryLifecyclePersistence(ag, sess)
1214 if sess != nil {
1215 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1216 }
1217
1218 piped, _ := readAllStdin()
1219 prompt := combinePromptInput(piped, args.Prompt)
1220 if prompt == "" {
1221 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1222 }
1223
1224 start := len(ag.Messages())
1225 enc := json.NewEncoder(os.Stdout)
1226 preSink := func(ev core.AgentEvent) {
1227 _ = enc.Encode(modes.EventToJSON(ev))
1228 }
1229 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1230 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1231 return err
1232 }
1233 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1234 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1235 return err
1236 }
1237 var persistCompaction func([]provider.Message) error
1238 if sess != nil {
1239 persistCompaction = sess.AppendCompaction
1240 }
1241 recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1242 transcriptStart := start
1243 if recovery.Compacted {
1244 transcriptStart = recovery.OutputStart
1245 }
1246 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1247 finalErr := persistErr
1248 if err != nil {
1249 finalErr = errors.Join(err, persistErr)
1250 }
1251 _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1252 return finalErr
1253 }
1254 return err
1255}
1256
1257// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1258// "!command" values execute via BashTool; other values are sent as an agent turn.
1259// shellOut receives live shell chunks (typically os.Stderr); sink receives
1260// agent events for plain-text pre (stream mode wires a live text sink).
1261func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1262 pre = strings.TrimSpace(pre)
1263 if pre == "" {
1264 return nil
1265 }
1266 if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1267 raw, err := json.Marshal(map[string]any{"command": cmd})
1268 if err != nil {
1269 return err
1270 }
1271 if shellOut != nil {
1272 fmt.Fprintf(shellOut, "$ %s\n", cmd)
1273 if f, ok := shellOut.(interface{ Sync() error }); ok {
1274 _ = f.Sync()
1275 }
1276 }
1277 progress := func(chunk string) {
1278 if shellOut == nil || chunk == "" {
1279 return
1280 }
1281 fmt.Fprint(shellOut, chunk)
1282 if f, ok := shellOut.(interface{ Sync() error }); ok {
1283 _ = f.Sync()
1284 }
1285 }
1286 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1287 res, err := bash.Execute(ctx, raw, progress)
1288 if err != nil {
1289 return fmt.Errorf("zutfile entry.pre: %w", err)
1290 }
1291 if res.IsError {
1292 var sb strings.Builder
1293 for _, c := range res.Content {
1294 if tb, ok := c.(provider.TextBlock); ok {
1295 sb.WriteString(tb.Text)
1296 }
1297 }
1298 msg := strings.TrimSpace(sb.String())
1299 if msg == "" {
1300 msg = "command failed"
1301 }
1302 return fmt.Errorf("zutfile entry.pre: %s", msg)
1303 }
1304 return nil
1305 }
1306 if ag == nil {
1307 return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1308 }
1309 if sink == nil {
1310 sink = func(core.AgentEvent) {}
1311 }
1312 return ag.Prompt(ctx, pre, nil, sink)
1313}
1314
1315var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1316
1317// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1318// skills and currently loaded extension tools) and updates the live
1319// agent's registry and system prompt. Used after /reload-ext and after
1320// zutfile entry.pre installs new skills or extensions.
1321// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1322// interactive, when non-nil, serializes the final commit with agent replacement.
1323func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1324 if ag == nil {
1325 return subagents.WebSearchDeny, nil
1326 }
1327 var webSearchPolicyGeneration uint64
1328 if interactive != nil {
1329 // Capture before Resolve: the final commit must fail if settings,
1330 // Telegram attachment, or a newer registry commit changes policy while
1331 // filesystem/config/extension discovery is in flight.
1332 webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1333 }
1334 resolved, err := Resolve(args, true)
1335 if err != nil {
1336 return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1337 }
1338 if sharedSandbox != nil {
1339 resolved.UseSandbox(sharedSandbox)
1340 }
1341 if extToolAdapter != nil {
1342 resolved.MergeExtensionTools(extToolAdapter)
1343 }
1344 reg := resolved.ToolRegistry
1345 if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1346 delete(reg, "web_search")
1347 }
1348 if mutateRegistry != nil {
1349 reg = mutateRegistry(reg)
1350 }
1351 if interactive != nil {
1352 oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1353 if !applied {
1354 _ = tools.CloseLSPManagers(reg)
1355 return subagents.WebSearchDeny, errInteractiveAgentChanged
1356 }
1357 interactive.DeferUntilIdle(func() {
1358 _ = tools.CloseLSPManagers(oldTools)
1359 })
1360 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1361 }
1362 oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1363 _ = tools.CloseLSPManagers(oldTools)
1364 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1365}
1366
1367func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1368 if policy == subagents.WebSearchAllow {
1369 if _, ok := reg["web_search"]; ok {
1370 return subagents.WebSearchAllow
1371 }
1372 }
1373 return subagents.WebSearchDeny
1374}
1375
1376func webSearchToolAllowedForSession(args Args) bool {
1377 if args.NoTools || args.PermissionSet != nil {
1378 return false
1379 }
1380 if args.ToolsSet || len(args.Tools) > 0 {
1381 return toolListContains(args.Tools, "web_search")
1382 }
1383 return true
1384}
1385
1386func webSearchExplicitlyEnabledForSession(args Args) bool {
1387 return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1388}
1389
1390// reloadResourcesAfterStartupPre reloads extensions (if any) and
1391// refreshes the agent tool registry + system prompt so skills/extensions
1392// installed by entry.pre are visible to the following turn.
1393func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1394 _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1395 return err
1396}
1397
1398func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
1399 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1400 return subagents.WebSearchDeny, nil
1401 }
1402 adapter := &extToolAdapter{mgr: extMgr}
1403 if extMgr != nil {
1404 _ = extMgr.Reload(ctx, 2*time.Second)
1405 }
1406 return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1407}
1408
1409// ---- interactive mode: opens the TUI even without credentials ----
1410
1411func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1412 initialCfg, _ := LoadConfig()
1413 // Resolve WITHOUT requiring credentials.
1414 r, err := Resolve(args, false)
1415 if err != nil {
1416 return err
1417 }
1418
1419 authStore := AuthStoreFor()
1420 mgr := auth.NewManager(authStore)
1421 defer mgr.Close()
1422
1423 // Keep the sandbox pointer stable across agent rebuilds (login / model
1424 // switch). The Interactive UI toggles the lock via this pointer, and
1425 // rebuilt tool instances must share the same one so the lock sticks.
1426 sharedSandbox := r.Sandbox
1427
1428 // persistMu guards the active session and selection across the agent
1429 // loop, TUI callbacks, and session swaps. Declare it before tool
1430 // construction because subagent defaults must read the live selection too.
1431 var persistMu sync.Mutex
1432 // Serialize session transitions with persistence callbacks so a candidate
1433 // is read only after the old agent is flushed and cannot be overwritten by
1434 // an in-flight append before commit.
1435 var sessionTransitionMu sync.RWMutex
1436 activeProvider := r.Provider
1437 activeModel := r.Model
1438
1439 // Build the extension manager BEFORE the agent so we can fold
1440 // extension-defined tools into the registry. Attach the interactive
1441 // host after constructing it below so startup alerts can be buffered.
1442 var iv *modes.Interactive
1443 extHooks := &interactiveExtHooks{}
1444 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1445 var startupExtensionErrors []string
1446 // --ext paths first so they win against installed extensions of
1447 // the same name (loadOne's first-write-wins semantics).
1448 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1449 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1450 }
1451 // --no-ext skips the global + project-local discovery scan;
1452 // explicit --ext paths above are still honoured so you can run
1453 // "only this extension" with --no-ext --ext ./x.
1454 if !args.NoExt {
1455 for _, e := range extMgr.Discover(ctx) {
1456 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1457 }
1458 }
1459 // Wait briefly for extensions to flush their initial register_tool
1460 // frames before we build the agent's tool registry. Half a second
1461 // is plenty for any extension that's actually well-behaved; ones
1462 // that don't send a ready frame eat the full grace and proceed.
1463 // 3s is the per-extension grace period for the ready frame.
1464 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1465 // from cold cache. The wait is tight only for extensions that
1466 // haven't sent ready by then; ones that signalled earlier release
1467 // the wait immediately.
1468 extMgr.WaitForReady(3 * time.Second)
1469 defer extMgr.Stop(2 * time.Second)
1470
1471 extToolAdapter := &extToolAdapter{mgr: extMgr}
1472 r.MergeExtensionTools(extToolAdapter)
1473 webSearchGuard := &webSearchSessionGuard{}
1474
1475 // Keep the shared subagent runtime in the outer scope: session reloads,
1476 // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1477 var runtime *subagentRuntime
1478 var subagentsMgr *subagents.Supervisor
1479 // These callbacks deliberately resolve iv lazily because the Interactive
1480 // instance is constructed after the agent and its registry.
1481 onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
1482 if iv != nil {
1483 iv.TrackSubagentWorker(a, task, required)
1484 }
1485 }
1486 onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
1487 if iv != nil {
1488 iv.TrackResumedSubagentWorker(a, prompt, required)
1489 }
1490 }
1491 onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
1492 if iv != nil {
1493 return iv.PrepareResumedSubagentWorker(a, prompt, required)
1494 }
1495 return nil
1496 }
1497 onStopRequestedSupervisor := func(a *subagents.Agent) {
1498 if iv != nil {
1499 iv.TrackStoppedSubagentWorker(a)
1500 }
1501 }
1502 activeProviderForSubagents := func() string {
1503 persistMu.Lock()
1504 defer persistMu.Unlock()
1505 return activeProvider
1506 }
1507 activeModelForSubagents := func() string {
1508 persistMu.Lock()
1509 defer persistMu.Unlock()
1510 return activeModel
1511 }
1512
1513 runtime = newSubagentRuntime(subagentRuntimeConfig{
1514 Context: ctx,
1515 Args: args,
1516 Root: filepath.Join(ZutHome(), "subagents"),
1517 RepoRoot: r.CWD,
1518 Provider: r.Provider,
1519 Model: r.Model,
1520 Reasoning: r.Reasoning,
1521 BaseURL: r.BaseURL,
1522 InsecureTLS: r.InsecureTLS,
1523 FastMode: r.FastMode,
1524 APIKey: args.APIKey,
1525 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1526 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1527 WebSearchGuard: webSearchGuard,
1528 ActiveProvider: activeProviderForSubagents,
1529 ActiveModel: activeModelForSubagents,
1530 OnSpawned: onSpawnedSupervisor,
1531 OnResumed: onResumedSupervisor,
1532 BeforeResumed: onBeforeResumedSupervisor,
1533 OnStopRequested: onStopRequestedSupervisor,
1534 })
1535 subagentsMgr = runtime.Supervisor()
1536 reloadDone := make(chan struct{})
1537 var reloadErrs []error
1538 runtime.SetRequiredWorkerReady(reloadDone, func() error {
1539 return errors.Join(reloadErrs...)
1540 })
1541 if subagentsMgr != nil {
1542 // Replaying historical event logs can take seconds. Populate the
1543 // detached-agent dashboard without blocking the first interactive paint.
1544 go func() {
1545 _, reloadErrs = subagentsMgr.Reload()
1546 close(reloadDone)
1547 }()
1548 } else {
1549 close(reloadDone)
1550 }
1551 defer func() {
1552 // Reload may attach detached workers, so let it finish before shutdown
1553 // snapshots the supervisor's worker set.
1554 <-reloadDone
1555 _ = closeSubagentRuntimeFresh(runtime)
1556 }()
1557
1558 prepareInteractiveRegistry := runtime.PrepareRegistry
1559 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1560 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1561 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1562 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1563 runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1564 resolveSubagent := runtime.resolveSubagent
1565
1566 // Confirmation gate: when --no-yolo is on, the agent must ask
1567 // the user before every tool call. In interactive mode the TUI
1568 // provides the Confirmer; in print/json/rpc modes there's no
1569 // way to prompt, so the gate is constructed with a nil inner
1570 // which auto-refuses every call with a helpful reason.
1571 var confirmGate *core.ConfirmGate
1572 if args.NoYolo {
1573 confirmGate = core.NewConfirmGate(nil) // set below for interactive
1574 }
1575
1576 // Capture current args in a closure so BuildAgent can re-resolve
1577 // after a successful login (picks up the newly stored credential).
1578 wireAgentExt := func(a *core.Agent) *core.Agent {
1579 if a == nil {
1580 return a
1581 }
1582 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1583 // Guards run before confirmation because they may rewrite the
1584 // arguments. The user must approve the effective call that will
1585 // actually execute, not the model's original arguments.
1586 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1587 if r.Block {
1588 return false, r.Reason, nil
1589 }
1590 effectiveArgs := call.Arguments
1591 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1592 effectiveArgs = r.ModifiedArgs
1593 }
1594 if confirmGate != nil {
1595 var content strings.Builder
1596 _, currentTools := a.PromptConfig()
1597 if tool, err := currentTools.Get(call.Name); err == nil {
1598 if previewer, ok := tool.(core.ToolPreviewer); ok {
1599 preview, err := previewer.Preview(ctx, effectiveArgs)
1600 if err != nil {
1601 return false, err.Error(), nil
1602 }
1603 for _, block := range preview.Content {
1604 if text, ok := block.(provider.TextBlock); ok {
1605 if content.Len() > 0 {
1606 content.WriteString("\n")
1607 }
1608 content.WriteString(text.Text)
1609 }
1610 }
1611 }
1612 }
1613 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1614 ID: call.ID,
1615 Name: call.Name,
1616 Summary: core.BuildPreview(effectiveArgs, 120),
1617 Content: content.String(),
1618 Origin: call.Origin,
1619 })
1620 if !ok {
1621 return false, reason, nil
1622 }
1623 }
1624 return true, "", r.ModifiedArgs
1625 }
1626 a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1627 r := extMgr.InterceptTurnStart(turnCtx, step)
1628 return !r.Block, r.Reason, r.Context
1629 }
1630 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1631 r := extMgr.InterceptAssistantMessage(ctx, text)
1632 if r.Block {
1633 return false, r.Reason, ""
1634 }
1635 return true, "", r.ReplaceText
1636 }
1637 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1638 runtime.WireRequiredWorkerGate(a)
1639 return a
1640 }
1641
1642 var ag *core.Agent
1643 buildAgent := func() (*core.Agent, string, string, error) {
1644 resolved, err := Resolve(args, true)
1645 if err != nil {
1646 return nil, "", "", err
1647 }
1648 resolved.UseSandbox(sharedSandbox)
1649 runtime.SetProvider(resolved.Provider)
1650 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1651 runtime.SetFastMode(resolved.FastMode)
1652 resolved.MergeExtensionTools(extToolAdapter)
1653 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1654 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1655 }
1656
1657 // Rebuild agent with an explicit provider/model override.
1658 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1659 next := args
1660 if providerOverride != "" {
1661 next.Provider = providerOverride
1662 }
1663 if modelOverride != "" {
1664 next.Model = modelOverride
1665 }
1666 resolved, err := Resolve(next, true)
1667 if err != nil {
1668 return nil, "", "", err
1669 }
1670 resolved.UseSandbox(sharedSandbox)
1671 runtime.SetProvider(resolved.Provider)
1672 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1673 runtime.SetFastMode(resolved.FastMode)
1674 resolved.MergeExtensionTools(extToolAdapter)
1675 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1676 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1677 }
1678
1679 // Rebuild agent for the rescue picker after a recoverable failure.
1680 // Unlike buildAgentFor, this drops launch-time --api-key and
1681 // --base-url overrides because those are typically the cause of the
1682 // rescue (a bad key, a typo'd base URL, or a corporate gateway that
1683 // only the originally-picked provider needed). Re-resolving without
1684 // them lets the rescue retry use env vars / auth.json / provider
1685 // defaults the way zut would have without the overrides.
1686 buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1687 next := args
1688 next.APIKey = ""
1689 next.BaseURL = ""
1690 if providerOverride != "" {
1691 next.Provider = providerOverride
1692 }
1693 if modelOverride != "" {
1694 next.Model = modelOverride
1695 }
1696 resolved, err := Resolve(next, true)
1697 if err != nil {
1698 return nil, "", "", err
1699 }
1700 resolved.UseSandbox(sharedSandbox)
1701 runtime.SetProvider(resolved.Provider)
1702 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1703 runtime.SetFastMode(resolved.FastMode)
1704 resolved.MergeExtensionTools(extToolAdapter)
1705 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1706 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1707 }
1708
1709 if r.HasCredential() {
1710 ag = wireAgentExt(r.NewAgent())
1711 }
1712
1713 // /reload-ext callback: after the manager has respawned every
1714 // extension, re-resolve the tool registry (built-ins + freshly-
1715 // registered extension tools) and swap it onto the current
1716 // agent in-place. The current agent may have been replaced by a
1717 // /model swap since spawn, so re-read the live `ag` on each
1718 // invocation.
1719 extMgr.SetOnReload(func() {
1720 current := liveInteractiveAgent(iv, ag)
1721 if current == nil {
1722 return
1723 }
1724 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1725 iv.ReportError(err)
1726 }
1727 })
1728
1729 var sess *core.Session
1730 var sessBaselineMsgs int // messages already on disk when current session opened
1731 var sessionTitlePending bool
1732 if !args.NoSess && ag != nil {
1733 var sessErr error
1734 sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1735 if sessErr != nil {
1736 return sessErr
1737 }
1738 sessBaselineMsgs = len(ag.Messages())
1739 if sess != nil {
1740 // Startup resume uses the same provider/model-aware preparation as
1741 // the interactive session picker. This matters for --continue,
1742 // --resume, and explicit --session paths whose latest meta row was
1743 // written by another provider/model.
1744 candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1745 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1746 if resumeErr != nil {
1747 return resumeErr
1748 }
1749 sess = candidate.session
1750 ag = candidate.agent
1751 sessBaselineMsgs = candidate.fullMessageCount
1752 activeProvider = candidate.provider
1753 activeModel = candidate.model
1754 r.Provider = candidate.provider
1755 r.Model = candidate.model
1756 }
1757 if sess != nil && ag != nil {
1758 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1759 }
1760 }
1761 announceSession(extMgr, sess)
1762 // Print the hint after the session close defer below and all terminal/TUI
1763 // cleanup defers. Close removes a fresh empty session, so only a session
1764 // that remains discoverable through the hinted command gets a hint.
1765 defer func() {
1766 if !iv.ExitedViaCtrlC() {
1767 return
1768 }
1769 persistMu.Lock()
1770 current := sess
1771 persistMu.Unlock()
1772 if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1773 fmt.Print(resumeSessionHint(resumeID))
1774 }
1775 }()
1776 defer func() {
1777 persistMu.Lock()
1778 defer persistMu.Unlock()
1779 runErr = joinSessionCloseError(runErr, sess)
1780 }()
1781
1782 // persistMessage is the per-message hook bound to the agent. It
1783 // appends each new transcript message to the live session as soon
1784 // as it lands, so a kill / closed terminal / OS crash costs at
1785 // most the in-flight turn instead of the whole session. The
1786 // baseline counter advances in lock-step so the exit-time flush
1787 // doesn't double-write rows already on disk.
1788 persistMessage := func(m provider.Message) {
1789 sessionTransitionMu.RLock()
1790 defer sessionTransitionMu.RUnlock()
1791 persistMu.Lock()
1792 defer persistMu.Unlock()
1793 if sess == nil {
1794 return
1795 }
1796 if err := sess.AppendMessage(m); err == nil {
1797 sessBaselineMsgs++
1798 }
1799 }
1800 persistUsage := func(cum provider.Usage) {
1801 sessionTransitionMu.RLock()
1802 defer sessionTransitionMu.RUnlock()
1803 persistMu.Lock()
1804 defer persistMu.Unlock()
1805 if sess == nil {
1806 return
1807 }
1808 _ = sess.AppendUsage(cum, cum)
1809 }
1810 persistRetryLifecycle := func(record core.RetryLifecycleRecord) {
1811 sessionTransitionMu.RLock()
1812 defer sessionTransitionMu.RUnlock()
1813 persistMu.Lock()
1814 defer persistMu.Unlock()
1815 if sess != nil {
1816 sess.RecordRetryLifecycle(record)
1817 }
1818 }
1819 persistCompaction := func(messages []provider.Message) {
1820 sessionTransitionMu.RLock()
1821 var session *extproto.SessionContext
1822 var states map[string]json.RawMessage
1823 persistMu.Lock()
1824 if sess != nil {
1825 if err := sess.AppendCompaction(messages); err == nil {
1826 sessBaselineMsgs = len(messages)
1827 session = sessionContext(sess)
1828 states = copyExtensionStates(sess.ExtensionState)
1829 }
1830 }
1831 persistMu.Unlock()
1832 sessionTransitionMu.RUnlock()
1833 if session != nil {
1834 extMgr.EmitSessionEvent("session_compacted", session, states)
1835 }
1836 }
1837 persistCompactHandoff := func(state json.RawMessage) error {
1838 sessionTransitionMu.RLock()
1839 defer sessionTransitionMu.RUnlock()
1840 persistMu.Lock()
1841 defer persistMu.Unlock()
1842 if sess == nil {
1843 return nil
1844 }
1845 return sess.UpdateCompactHandoff(state)
1846 }
1847 currentCompactHandoff := func() json.RawMessage {
1848 persistMu.Lock()
1849 defer persistMu.Unlock()
1850 if sess == nil {
1851 return nil
1852 }
1853 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1854 }
1855 persistGoal := func(goal *core.SessionGoal) error {
1856 sessionTransitionMu.RLock()
1857 defer sessionTransitionMu.RUnlock()
1858 persistMu.Lock()
1859 defer persistMu.Unlock()
1860 if sess == nil {
1861 return errors.New("session persistence is disabled")
1862 }
1863 return sess.UpdateGoal(goal)
1864 }
1865 ensureMission := func(objective string) error {
1866 sessionTransitionMu.RLock()
1867 defer sessionTransitionMu.RUnlock()
1868 persistMu.Lock()
1869 defer persistMu.Unlock()
1870 if sess == nil {
1871 return errors.New("session persistence is disabled")
1872 }
1873 return sess.EnsureMission(objective, core.MissionSourceUser)
1874 }
1875 currentGoal := func() *core.SessionGoal {
1876 persistMu.Lock()
1877 defer persistMu.Unlock()
1878 if sess == nil || sess.Meta.Goal == nil {
1879 return nil
1880 }
1881 goal := *sess.Meta.Goal
1882 return &goal
1883 }
1884 currentGoalHistory := func() []core.SessionGoal {
1885 persistMu.Lock()
1886 defer persistMu.Unlock()
1887 if sess == nil {
1888 return nil
1889 }
1890 return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...)
1891 }
1892 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1893 if a == nil {
1894 return
1895 }
1896 a.CommitToolResult = func(_ string, result core.ToolResult) error {
1897 persistMu.Lock()
1898 defer persistMu.Unlock()
1899 return persistToolResultState(extMgr, boundSession, result)
1900 }
1901 }
1902 wireAgentPersist := func(a *core.Agent) *core.Agent {
1903 if a == nil {
1904 return a
1905 }
1906 persistMu.Lock()
1907 currentSession := sess
1908 persistMu.Unlock()
1909 if currentSession != nil {
1910 a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1911 }
1912 a.OnMessageAppended = persistMessage
1913 a.OnUsage = persistUsage
1914 a.OnRetryLifecycle = persistRetryLifecycle
1915 a.OnTranscriptCompacted = persistCompaction
1916 bindAgentToolResultSession(a, currentSession)
1917 return a
1918 }
1919 wireAgentPersist(ag)
1920 defer func() {
1921 closeAgentLSP(liveInteractiveAgent(iv, ag))
1922 closeResolvedLSP(r)
1923 }()
1924
1925 // Re-wrap the build closures so any agent constructed by the TUI
1926 // (login, /model swap to a different provider) also gets the
1927 // persistence hooks. Without this, switching provider would
1928 // silently revert to the old in-memory-only behaviour. Selection state
1929 // is published by the successful transition itself, not by preparation.
1930 baseBuildAgent := buildAgent
1931 buildAgent = func() (*core.Agent, string, string, error) {
1932 a, p, m, err := baseBuildAgent()
1933 return wireAgentPersist(a), p, m, err
1934 }
1935 baseBuildAgentFor := buildAgentFor
1936 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1937 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1938 return wireAgentPersist(a), p, m, err
1939 }
1940 baseBuildAgentForRescue := buildAgentForRescue
1941 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1942 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1943 return wireAgentPersist(a), p, m, err
1944 }
1945
1946 // loadSession replaces the current session with the one at path and
1947 // hands its messages to the agent. Used by the /sessions picker.
1948 loadSession := func(path string) (loadErr error) {
1949 // Hold the transition lock from the pre-flush through the commit.
1950 // Persistence callbacks take the read side, so an active session
1951 // cannot be snapshotted before its lazy writes land or overwritten by
1952 // a callback while the candidate is being installed.
1953 sessionTransitionMu.Lock()
1954 defer sessionTransitionMu.Unlock()
1955
1956 currentAg := liveInteractiveAgent(iv, ag)
1957 if currentAg == nil {
1958 return fmt.Errorf("no agent running; log in first")
1959 }
1960
1961 persistMu.Lock()
1962 currentProvider := activeProvider
1963 currentModel := activeModel
1964 oldSess := sess
1965 // Flush before reading the candidate. Use the locked form rather
1966 // than the public callback, which would acquire the read lock held
1967 // by this transition.
1968 if oldSess != nil {
1969 next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1970 sessBaselineMsgs = next
1971 if flushErr != nil {
1972 persistMu.Unlock()
1973 return fmt.Errorf("flush current session: %w", flushErr)
1974 }
1975 }
1976 currentMessageCount := len(currentAg.Messages())
1977 currentCost := currentAg.Cost()
1978 persistMu.Unlock()
1979
1980 // A candidate build must not publish its provider/model as the live
1981 // selection before the candidate commits. The builder also stages
1982 // provider settings on the shared runtime, so restore those settings on
1983 // every failed path, including errors returned during preparation.
1984 runtimeConfig := runtime.snapshotConfiguration()
1985 committed := false
1986 var candidate sessionResumeCandidate
1987 defer func() {
1988 if !committed {
1989 runtime.restoreConfiguration(runtimeConfig)
1990 loadErr = joinSessionCloseError(loadErr, candidate.session)
1991 }
1992 }()
1993 candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1994 if err != nil {
1995 // prepareSessionResume closes its append handle on failure;
1996 // no live agent or session has been changed.
1997 return err
1998 }
1999
2000 // Wire the private candidate before it can become visible. This is
2001 // also required for legacy same-provider resumes, where the current
2002 // agent is reused and only its transcript/usage are refreshed.
2003 wireAgentPersist(candidate.agent)
2004
2005 persistMu.Lock()
2006 // A provider rebuild ran outside persistMu. Refuse to install it if
2007 // the live agent, session, selection, transcript, or cost changed in
2008 // the meantime; otherwise a slow resume could discard a newer turn or
2009 // model swap.
2010 live := liveInteractiveAgent(iv, ag)
2011 changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
2012 if !changed && live != nil {
2013 changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
2014 }
2015 if changed {
2016 persistMu.Unlock()
2017 return fmt.Errorf("session changed while loading; please try again")
2018 }
2019 newStates := copyExtensionStates(candidate.session.ExtensionState)
2020 var oldCloseErr error
2021 if oldSess != nil {
2022 oldCloseErr = oldSess.Close()
2023 }
2024 sess = candidate.session
2025 bindAgentToolResultSession(candidate.agent, candidate.session)
2026 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
2027 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
2028 // The live agent only receives a compact resume window, but the
2029 // session file remains intact. Keep the persistence baseline at
2030 // the original on-disk message count so future turns append after
2031 // the full session instead of duplicating the hydrated tail.
2032 sessBaselineMsgs = candidate.fullMessageCount
2033
2034 if candidate.rebuilt {
2035 // Commit the TUI agent while persistMu still protects the session
2036 // writer, so no new message can land on the old file between the
2037 // two state changes.
2038 if iv != nil {
2039 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2040 }
2041 ag = candidate.agent
2042 } else {
2043 currentAg.SetMessages(candidate.messages)
2044 currentAg.SeedCost(candidate.cumulative)
2045 currentAg.SeedLastTurnUsage(candidate.lastTurn)
2046 ag = currentAg
2047 }
2048 activeProvider = candidate.provider
2049 activeModel = candidate.model
2050 if subagentsMgr != nil && candidate.session != nil {
2051 // Keep the dashboard scope in the same commit as the session,
2052 // agent, usage, and persistence baseline.
2053 if iv != nil {
2054 iv.SetSubagentSessionScope(candidate.session.ID)
2055 } else {
2056 runtime.SetActiveSession(candidate.session.ID)
2057 }
2058 }
2059 committed = true
2060 persistMu.Unlock()
2061 if iv != nil {
2062 iv.RefreshGoal()
2063 }
2064 if candidate.session.Meta.Parent != "" {
2065 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
2066 }
2067 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
2068 if oldCloseErr != nil {
2069 return fmt.Errorf("close current session: %w", oldCloseErr)
2070 }
2071 return nil
2072 }
2073
2074 // changeCWD switches the running session to a new working directory.
2075 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
2076 // /cd slash command (which itself is only fired by the workspaces
2077 // extension's panel-key Enter handler today; the user can type /cd
2078 // directly but it's not in autocomplete / help / the README).
2079 //
2080 // Steps, in order:
2081 // 1. resolve + validate the new path (~ expansion, abs/rel)
2082 // 2. stage the candidate cwd, sandbox, and subagent state
2083 // 3. rebuild the agent and allocate its new session
2084 // 4. commit all new state together, closing the old session last
2085 // 5. push the committed state into the running Interactive
2086 //
2087 // The /jail state is preserved verbatim: if the sandbox was locked
2088 // to the old cwd, it stays locked, just re-pointed at the new one.
2089 changeCWD := func(path string) error {
2090 sessionTransitionMu.Lock()
2091 defer sessionTransitionMu.Unlock()
2092 if path == "" {
2093 return fmt.Errorf("empty path")
2094 }
2095 // ~ expansion.
2096 if path == "~" || strings.HasPrefix(path, "~/") {
2097 home, herr := os.UserHomeDir()
2098 if herr != nil || home == "" {
2099 return fmt.Errorf("cannot expand ~: %v", herr)
2100 }
2101 if path == "~" {
2102 path = home
2103 } else {
2104 path = filepath.Join(home, path[2:])
2105 }
2106 }
2107 if !filepath.IsAbs(path) {
2108 path = filepath.Join(args.CWD, path)
2109 }
2110 absPath, err := filepath.Abs(path)
2111 if err != nil {
2112 return err
2113 }
2114 info, err := os.Stat(absPath)
2115 if err != nil {
2116 return err
2117 }
2118 if !info.IsDir() {
2119 return fmt.Errorf("not a directory: %s", absPath)
2120 }
2121
2122 currentAg := liveInteractiveAgent(iv, ag)
2123 if currentAg == nil {
2124 return fmt.Errorf("no agent running; log in first")
2125 }
2126
2127 // Stage the candidate state while retaining a complete rollback
2128 // snapshot. A failed rebuild or session allocation must leave the
2129 // current agent, session, sandbox, and subagent root usable.
2130 oldCWD := args.CWD
2131 oldResolvedCWD := r.CWD
2132 oldPermissionSet := args.PermissionSet
2133 persistMu.Lock()
2134 oldActiveProvider := activeProvider
2135 oldActiveModel := activeModel
2136 persistMu.Unlock()
2137 runtimeConfig := runtime.snapshotConfiguration()
2138 oldSandboxRoot := ""
2139 oldSandboxLocked := false
2140 if sharedSandbox != nil {
2141 oldSandboxRoot = sharedSandbox.Root
2142 oldSandboxLocked = sharedSandbox.Locked()
2143 }
2144 rollback := func() {
2145 args.CWD = oldCWD
2146 r.CWD = oldResolvedCWD
2147 args.PermissionSet = oldPermissionSet
2148 persistMu.Lock()
2149 activeProvider = oldActiveProvider
2150 activeModel = oldActiveModel
2151 persistMu.Unlock()
2152 runtime.restoreConfiguration(runtimeConfig)
2153 if sharedSandbox != nil {
2154 sharedSandbox.Root = oldSandboxRoot
2155 if oldSandboxLocked {
2156 sharedSandbox.Lock()
2157 } else {
2158 sharedSandbox.Unlock()
2159 }
2160 }
2161 }
2162
2163 args.CWD = absPath
2164 r.CWD = absPath
2165 runtime.SetRepoRoot(absPath)
2166 if args.PermissionSet != nil {
2167 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2168 args.PermissionSet = &expanded
2169 }
2170 if sharedSandbox != nil {
2171 sharedSandbox.Root = absPath
2172 if oldSandboxLocked {
2173 sharedSandbox.Lock()
2174 } else {
2175 sharedSandbox.Unlock()
2176 }
2177 }
2178
2179 // Rebuild the agent so tools / AGENTS.md / system prompt
2180 // re-bind to the new cwd. buildAgent() reads from args + r.
2181 newAg, newProvider, newModel, berr := buildAgent()
2182 if berr != nil {
2183 rollback()
2184 return fmt.Errorf("rebuild agent: %w", berr)
2185 }
2186
2187 // Fresh session in the new cwd's bucket. We bypass
2188 // openOrCreateSession's --continue / --resume branches
2189 // because /cd's semantics are "start fresh here", matching
2190 // what relaunching `zut --cwd <path>` would do today.
2191 var newSess *core.Session
2192 if !args.NoSess {
2193 newRoot := agentSessionsRoot(ZutHome(), args)
2194 core.PruneEmptySessions(newRoot, absPath)
2195 var serr error
2196 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2197 if serr != nil {
2198 rollback()
2199 return fmt.Errorf("open session in %s: %w", absPath, serr)
2200 }
2201 }
2202
2203 // Invalidate any title request before swapping the host-owned session
2204 // pointer. The interactive state reset below happens after the swap,
2205 // so this guard closes the window where a late result could otherwise
2206 // be written to the new session.
2207 if iv != nil {
2208 iv.CancelPendingSessionTitle()
2209 }
2210
2211 // Commit only after every fallible operation has succeeded. Close
2212 // the old session last so rollback never has to resurrect it.
2213 var newStates map[string]json.RawMessage
2214 var oldCloseErr error
2215 persistMu.Lock()
2216 if sess != nil {
2217 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2218 sessBaselineMsgs = next
2219 if flushErr != nil {
2220 persistMu.Unlock()
2221 rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2222 rollback()
2223 return rollbackErr
2224 }
2225 oldCloseErr = sess.Close()
2226 }
2227 sess = newSess
2228 bindAgentToolResultSession(newAg, newSess)
2229 if newSess != nil {
2230 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2231 newStates = copyExtensionStates(newSess.ExtensionState)
2232 }
2233 sessBaselineMsgs = 0
2234 sessionTitlePending = false
2235 activeProvider = newProvider
2236 activeModel = newModel
2237 persistMu.Unlock()
2238 ag = newAg
2239 closeAgentLSP(currentAg)
2240
2241 // Push the new state into the running Interactive.
2242 if iv != nil {
2243 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2244 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2245 iv.RefreshGoal()
2246 }
2247
2248 // Re-scope the subagent dashboard to the new session.
2249 if newSess != nil {
2250 if iv != nil {
2251 iv.SetSubagentSessionScope(newSess.ID)
2252 } else {
2253 runtime.SetActiveSession(newSess.ID)
2254 }
2255 }
2256 if newSess != nil {
2257 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2258 }
2259 if oldCloseErr != nil {
2260 return fmt.Errorf("close current session: %w", oldCloseErr)
2261 }
2262 return nil
2263 }
2264
2265 devBuild := isDevVersion(version)
2266 if devBuild {
2267 fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2268 }
2269 term := tui.NewProcTerm()
2270
2271 var updateCh chan modes.UpdateInfo
2272 var changelogCh chan modes.ChangelogPayload
2273 if !devBuild {
2274 // Kick off the async update check so the banner can appear when the
2275 // http response eventually arrives (usually <1s on cached DNS). Map
2276 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2277 updateCh = make(chan modes.UpdateInfo, 1)
2278 go func() {
2279 defer close(updateCh)
2280 src := <-CheckForUpdateAsync(ZutHome(), version)
2281 updateCh <- modes.UpdateInfo{
2282 Current: src.Current,
2283 Latest: src.Latest,
2284 Available: src.Available,
2285 URL: src.URL,
2286 }
2287 }()
2288
2289 // Changelog: when the running version differs from the last
2290 // version whose release notes the user dismissed, fetch the
2291 // release body from GitHub and have the TUI show it once. On
2292 // first-ever launch (no prior LastChangelogShown), seed the
2293 // stored version silently — don't dump release notes at someone
2294 // who just installed.
2295 changelogCh = make(chan modes.ChangelogPayload, 1)
2296 go func() {
2297 defer close(changelogCh)
2298 cfg, _ := LoadConfig()
2299 if cfg.LastChangelogShown == "" {
2300 SeedChangelogVersion(version)
2301 return
2302 }
2303 if !ShouldShowChangelog(version, cfg) {
2304 return
2305 }
2306 info := <-FetchChangelogAsync(version)
2307 if info.Body == "" {
2308 return
2309 }
2310 changelogCh <- modes.ChangelogPayload{
2311 Version: info.Version,
2312 Body: info.Body,
2313 URL: info.URL,
2314 }
2315 }()
2316 }
2317
2318 fastMode := r.FastMode
2319 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2320 for idx, s := range initialCfg.QuickModelShortcuts {
2321 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2322 }
2323 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2324 if themeErr != nil {
2325 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2326 if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2327 initialCfg.Theme = ""
2328 _ = SaveConfig(initialCfg)
2329 }
2330 }
2331
2332 // Scope the dashboard to the active host session. The runtime owns
2333 // shutdown separately and closes the supervisor exactly once.
2334 if sess != nil {
2335 runtime.SetActiveSession(sess.ID)
2336 }
2337
2338 var startupSkills []*skills.Skill
2339 if r.SkillTool != nil {
2340 startupSkills = r.SkillTool.Skills()
2341 }
2342 homeDir, _ := os.UserHomeDir()
2343 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2344 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2345 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2346 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2347 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2348 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2349 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2350 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2351 setWebSearchAvailable := func(available bool) {
2352 webSearchGuard.setAvailable(available)
2353 if subagentsMgr == nil {
2354 return
2355 }
2356 policy := subagents.WebSearchDeny
2357 if available {
2358 policy = subagents.WebSearchAllow
2359 }
2360 runtime.SetWebSearchPolicy(policy)
2361 }
2362
2363 iv = modes.NewInteractive(modes.InteractiveConfig{
2364 Terminal: term,
2365 Theme: theme,
2366 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2367 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2368 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2369 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2370 PonytailEnabled: initialCfg.PonytailEnabled,
2371 WebSearchEnabled: initialCfg.WebSearchEnabled,
2372 WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2373 WebSearchInvocationOverride: webSearchInvocationOverride,
2374 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2375 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2376 AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2377 AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2378 FastMode: &fastMode,
2379 LSPEnabled: initialCfg.LSPEnabled,
2380 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2381 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2382 JailByDefault: initialCfg.JailByDefault,
2383 QuickModelShortcuts: quickModelShortcuts,
2384 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2385 RespectGitignore: initialCfg.RespectGitignore,
2386 CompactMode: initialCfg.CompactMode,
2387 TUIInputStyle: initialCfg.TUIInputStyle,
2388 TUIStatusPosition: initialCfg.TUIStatusPosition,
2389 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2390 TUISubagentPosition: initialCfg.TUISubagentPosition,
2391 ThemeName: initialCfg.Theme,
2392 FlatTools: initialCfg.FlatToolRender(),
2393 CompactUser: initialCfg.CompactUserInput(),
2394 ExtensionThemes: extMgr.ThemeOptions,
2395 AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2396 autoSubagentsToolAllowedForSession,
2397 autoSubagentsStopToolAllowedForSession,
2398 autoSubagentsResumeToolAllowedForSession,
2399 ),
2400 OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2401 SubagentsSystemAddendum: subagentsAddendum,
2402 SettingsStore: configSettingsStore{},
2403 Model: r.Model,
2404 Provider: r.Provider,
2405 AuthMethod: r.AuthMethod,
2406 BaseURL: r.BaseURL,
2407 Reasoning: r.Reasoning,
2408 OnReasoningChanged: runtime.SetReasoning,
2409 SystemPrompt: r.SystemPrompt,
2410 Tools: r.ToolRegistry,
2411 MaxSteps: r.MaxSteps,
2412 CWD: r.CWD,
2413 StartupAgentName: args.AgentName,
2414 StartupContextPaths: instructionContextPaths(r.ContextFiles),
2415 StartupExtensionNames: startupExtensionNames(extMgr.All()),
2416 StartupExtensionErrors: startupExtensionErrors,
2417 StartupSkillNames: startupSkillNames(startupSkills),
2418 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2419 ZutHome: ZutHome(),
2420 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2421 Version: version,
2422 UpdateInfoChan: updateCh,
2423 Sandbox: sharedSandbox,
2424 Agent: ag,
2425 InitialCompactHandoff: currentCompactHandoff(),
2426 InitialSessionTitle: func() string {
2427 if sess == nil || sessionTitlePending {
2428 return ""
2429 }
2430 return sess.Title
2431 }(),
2432 InitialSessionTitlePending: sessionTitlePending,
2433 InitialInput: args.Prompt,
2434 StartupPre: args.StartupPre,
2435 OnStartupPreDone: func() {
2436 // entry.pre often installs skills/extensions; rediscover them
2437 // before the user starts a model turn.
2438 current := liveInteractiveAgent(iv, ag)
2439 if extMgr != nil {
2440 _ = extMgr.Reload(context.Background(), 2*time.Second)
2441 }
2442 if current != nil {
2443 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2444 iv.ReportError(err)
2445 }
2446 }
2447 },
2448 RefreshTools: func() error {
2449 current := liveInteractiveAgent(iv, ag)
2450 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2451 if err != nil {
2452 if !errors.Is(err, errInteractiveAgentChanged) {
2453 setWebSearchAvailable(false)
2454 }
2455 return err
2456 }
2457 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2458 return nil
2459 },
2460 SetWebSearchAvailable: setWebSearchAvailable,
2461 RefreshPrompt: func() error {
2462 current := liveInteractiveAgent(iv, ag)
2463 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2464 if err != nil {
2465 return err
2466 }
2467 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2468 return nil
2469 },
2470 AuthManager: mgr,
2471 LlamaCPPConfig: ResolveLlamaCPPConfig,
2472 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2473 BuildAgent: buildAgent,
2474 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2475 BuildAgentFor: buildAgentFor,
2476 BuildAgentForRescue: buildAgentForRescue,
2477 LoggedInProviders: func() []string {
2478 var out []string
2479 seen := map[string]bool{}
2480 for _, p := range knownProviders {
2481 if CredentialAvailable(p) && !seen[p] {
2482 out = append(out, p)
2483 seen[p] = true
2484 }
2485 }
2486 // Include custom providers that have credentials stored.
2487 for p := range provider.CustomProviders() {
2488 if CredentialAvailable(p) && !seen[p] {
2489 out = append(out, p)
2490 seen[p] = true
2491 }
2492 }
2493 // Ollama models are always available (no auth needed).
2494 if !seen["ollama"] {
2495 out = append(out, "ollama")
2496 }
2497 return out
2498 },
2499 LoadSession: loadSession,
2500 ChangeCWD: changeCWD,
2501 PersistCompactHandoff: persistCompactHandoff,
2502 CurrentCompactHandoff: currentCompactHandoff,
2503 PersistGoal: persistGoal,
2504 CurrentGoal: currentGoal,
2505 CurrentGoalHistory: currentGoalHistory,
2506 EnsureMission: ensureMission,
2507 CurrentSessionPath: func() string {
2508 persistMu.Lock()
2509 defer persistMu.Unlock()
2510 if sess == nil {
2511 return ""
2512 }
2513 return sess.Path
2514 },
2515 FlushSession: func() {
2516 sessionTransitionMu.RLock()
2517 defer sessionTransitionMu.RUnlock()
2518 // Append any not-yet-persisted agent messages to the
2519 // current session file, then advance the baseline so
2520 // the final WriteNewTranscript at exit doesn't write
2521 // duplicates. Per-message persistence keeps the on-
2522 // disk file current already, so this is mostly a
2523 // defensive flush — still needed for /session export
2524 // to guarantee the exported bytes include the very
2525 // last in-flight turn.
2526 currentAg := iv.Agent()
2527 if currentAg == nil {
2528 return
2529 }
2530 persistMu.Lock()
2531 defer persistMu.Unlock()
2532 if sess == nil {
2533 return
2534 }
2535 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2536 sessBaselineMsgs = next
2537 if flushErr != nil {
2538 iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2539 return
2540 }
2541 },
2542 SessionTransition: newSessionTransition(&sessionTransitionMu),
2543 Extensions: extMgr,
2544 Supervisor: subagentsMgr,
2545 ResolveSubagent: resolveSubagent,
2546 ChangelogChan: changelogCh,
2547 OnChangelogDismiss: func() {
2548 // For development builds, store the actual release version
2549 // so the same changelog doesn't show again next launch.
2550 // For real builds, store the binary version.
2551 v := version
2552 if devBuild {
2553 if iv != nil && iv.ChangelogVersion() != "" {
2554 v = iv.ChangelogVersion()
2555 }
2556 }
2557 _ = MarkChangelogShown(v)
2558 },
2559 SkillSnapshot: func() []*skills.Skill {
2560 if args.NoSkill {
2561 // --no-skill: nothing for the picker to show.
2562 return nil
2563 }
2564 // Re-discover so the picker reflects edits made during
2565 // the session. Cheap; SKILL.md files are small. Filter
2566 // out built-in skills — they're hidden from user-facing
2567 // surfaces because they're implementation detail; the
2568 // model still sees them through the system-prompt
2569 // manifest and the skill tool.
2570 userHome, _ := os.UserHomeDir()
2571 list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2572 list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2573 return skills.VisibleSkills(list)
2574 },
2575 NoYolo: args.NoYolo,
2576 ConfirmGate: confirmGate,
2577 PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2578 if iv != nil {
2579 iv.Notify("session", "error", message)
2580 }
2581 }),
2582 CurrentSessionTitle: func() string {
2583 sessionTransitionMu.RLock()
2584 defer sessionTransitionMu.RUnlock()
2585 persistMu.Lock()
2586 defer persistMu.Unlock()
2587 if sess == nil || sessionTitlePending {
2588 return ""
2589 }
2590 return sess.Title
2591 },
2592 CurrentSessionTitlePending: func() bool {
2593 sessionTransitionMu.RLock()
2594 defer sessionTransitionMu.RUnlock()
2595 persistMu.Lock()
2596 defer persistMu.Unlock()
2597 return sessionTitlePending
2598 },
2599 PersistTitle: func(title string) error {
2600 persistMu.Lock()
2601 defer persistMu.Unlock()
2602 if sess == nil {
2603 return nil
2604 }
2605 if err := sess.UpdateTitle(title); err != nil {
2606 return err
2607 }
2608 sessionTitlePending = false
2609 return nil
2610 },
2611 OnSessionTitleChanged: func(title string) {
2612 sessionTransitionMu.RLock()
2613 defer sessionTransitionMu.RUnlock()
2614 persistMu.Lock()
2615 defer persistMu.Unlock()
2616 if sess != nil {
2617 sess.Title = title
2618 sessionTitlePending = false
2619 }
2620 },
2621 })
2622 extHooks.attachInteractive(iv)
2623 go func() {
2624 <-reloadDone
2625 for _, reloadErr := range reloadErrs {
2626 if reloadErr != nil {
2627 iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2628 }
2629 }
2630 }()
2631
2632 // Bind the interactive TUI as the Confirmer. We deferred this
2633 // until now because the gate is constructed before the TUI
2634 // (the BeforeToolExecute closure captures it). SetConfirmer
2635 // is mutex-guarded on the gate so this is safe.
2636 if confirmGate != nil {
2637 confirmGate.SetConfirmer(&confirmationEventConfirmer{
2638 inner: iv,
2639 emit: extMgr.EmitEvent,
2640 })
2641 }
2642
2643 // Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2644 // (closed terminal window, system shutdown, kill) used to lose
2645 // the entire in-memory transcript because the deferred post-Run
2646 // flush below never ran. Per-message persistence above covers
2647 // most of it; this handler writes any in-flight remainder and
2648 // then exits the process so we don't double-paint over a
2649 // broken terminal that the TUI's restore deferreds can no
2650 // longer fix from a signal context.
2651 //
2652 // SIGINT is intentionally NOT handled here — the TUI consumes
2653 // Ctrl+C as a regular key event for cancel/clear semantics, and
2654 // installing a SIGINT notifier here would swallow it.
2655 sigCh := make(chan os.Signal, 1)
2656 signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2657 defer signal.Stop(sigCh)
2658 go func() {
2659 _, ok := <-sigCh
2660 if !ok {
2661 return
2662 }
2663 finalAg := iv.Agent()
2664 if finalAg != nil {
2665 persistMu.Lock()
2666 if sess != nil {
2667 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2668 sessBaselineMsgs = next
2669 if flushErr != nil {
2670 fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2671 }
2672 if closeErr := sess.Close(); closeErr != nil {
2673 fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2674 }
2675 sess = nil
2676 }
2677 persistMu.Unlock()
2678 }
2679 closeAgentLSP(finalAg)
2680 closeResolvedLSP(r)
2681 // Stop supervised workers before exiting. os.Exit skips deferred
2682 // cleanup, so wait for reload and close the shared runtime explicitly.
2683 <-reloadDone
2684 _ = closeSubagentRuntimeFresh(runtime)
2685 // Exit cleanly. Re-raising the signal would skip os.Exit's
2686 // at-exit hooks; explicit exit is fine because we've already
2687 // flushed the only at-risk state (the session file) and stopped
2688 // supervised workers.
2689 os.Exit(0)
2690 }()
2691
2692 runErr = iv.Run(ctx)
2693
2694 // Flush final transcript to session (only if we had / ended up with an agent).
2695 if finalAg := iv.Agent(); finalAg != nil {
2696 persistMu.Lock()
2697 if sess != nil {
2698 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2699 sessBaselineMsgs = next
2700 if flushErr != nil {
2701 runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2702 }
2703 }
2704 persistMu.Unlock()
2705 }
2706 return runErr
2707}
2708
2709func resumableSessionID(root string, sess *core.Session) string {
2710 if sess == nil || sess.ID == "" || sess.Path == "" {
2711 return ""
2712 }
2713 path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2714 if err != nil || !sameSessionPath(path, sess.Path) {
2715 return ""
2716 }
2717 snapshot, err := core.ReadSessionSnapshot(path)
2718 if err != nil || len(snapshot.Messages) == 0 {
2719 return ""
2720 }
2721 return sess.ID
2722}
2723
2724func sameSessionPath(left, right string) bool {
2725 leftAbs, leftErr := filepath.Abs(left)
2726 rightAbs, rightErr := filepath.Abs(right)
2727 if leftErr == nil && rightErr == nil {
2728 return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2729 }
2730 return filepath.Clean(left) == filepath.Clean(right)
2731}
2732
2733func resumeSessionHint(id string) string {
2734 return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2735}
2736
2737func agentSessionsRoot(root string, args Args) string {
2738 if args.AgentName == "" {
2739 return root
2740 }
2741 return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2742}
2743
2744// openOrCreateSession returns a session for the run. sess may be nil
2745// with a nil error if session persistence is disabled.
2746func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2747 if args.NoSess {
2748 return nil, nil
2749 }
2750 // Sweep meta-only files left over from older zut versions (and from
2751 // any session that crashed before its first AppendMessage) for the
2752 // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2753 // can report metadata failures instead of turning them into misses.
2754 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2755 if args.ResumeSessionID == "" {
2756 core.PruneEmptySessions(sessionsRoot, args.CWD)
2757 }
2758 var (
2759 s *core.Session
2760 msgs []provider.Message
2761 err error
2762 )
2763 switch {
2764 case args.Session != "":
2765 s, msgs, err = core.OpenSession(args.Session)
2766 // The subagent-worker child passes a fixed --session path that
2767 // may not exist yet on first Spawn. Treat ENOENT as "create
2768 // a fresh session AT THIS PATH" so the conversation actually
2769 // gets persisted; without this fallback the subagent child runs
2770 // with sess==nil and every Resume re-starts with no memory
2771 // of the prior turns. Other openers (--continue / --resume /
2772 // the picker) never see ENOENT here because they only choose
2773 // paths that already exist on disk.
2774 if err != nil && errors.Is(err, os.ErrNotExist) {
2775 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2776 msgs = nil
2777 }
2778 case args.Continue:
2779 latest := core.LatestSession(sessionsRoot, args.CWD)
2780 if latest != "" {
2781 s, msgs, err = core.OpenSession(latest)
2782 }
2783 case args.Resume:
2784 if args.ResumeSessionID != "" {
2785 picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2786 if lookupErr != nil {
2787 return nil, lookupErr
2788 }
2789 if picked == "" {
2790 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2791 }
2792 s, msgs, err = core.OpenSession(picked)
2793 break
2794 }
2795 picked, perr := pickSession(sessionsRoot, args.CWD)
2796 if perr != nil {
2797 return nil, perr
2798 }
2799 if picked != "" {
2800 s, msgs, err = core.OpenSession(picked)
2801 }
2802 }
2803 if err != nil {
2804 return nil, err
2805 }
2806 if s != nil {
2807 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2808 ag.SetMessages(msgs)
2809 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2810 ag.SeedCost(cum)
2811 ag.SeedLastTurnUsage(last)
2812 }
2813 return s, nil
2814 }
2815 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2816 if err == nil {
2817 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2818 }
2819 return s, err
2820}
2821
2822func pickSession(root, cwd string) (string, error) {
2823 files := core.ListSessions(root, cwd)
2824 if len(files) == 0 {
2825 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2826 return "", nil
2827 }
2828 for i, f := range files {
2829 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
2830 }
2831 fmt.Fprint(os.Stderr, "pick #: ")
2832 rd := bufio.NewReader(os.Stdin)
2833 line, _ := rd.ReadString('\n')
2834 line = strings.TrimSpace(line)
2835 var n int
2836 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2837 return "", fmt.Errorf("invalid selection")
2838 }
2839 return files[n-1], nil
2840}
2841
2842// WriteNewTranscript appends only messages after index `from` from the
2843// agent's transcript to the session. Used by callers that don't hold
2844// the persistMu (non-interactive print/json modes which run a single
2845// turn under their own goroutine).
2846func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2847 _, err := writeNewTranscriptLocked(ag, sess, from)
2848 return err
2849}
2850
2851func wireRetryLifecyclePersistence(ag *core.Agent, sess *core.Session) {
2852 if ag == nil {
2853 return
2854 }
2855 if sess == nil {
2856 ag.OnRetryLifecycle = nil
2857 return
2858 }
2859 ag.OnRetryLifecycle = sess.RecordRetryLifecycle
2860}
2861
2862// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2863// suffix marks that interactive callers must hold persistMu when
2864// invoking it so concurrent appends from the agent loop don't race
2865// with this catch-up flush.
2866func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2867 next = from
2868 if sess == nil || ag == nil {
2869 return next, nil
2870 }
2871 msgs := ag.Messages()
2872 for i := from; i < len(msgs); i++ {
2873 if err := sess.AppendMessage(msgs[i]); err != nil {
2874 return next, fmt.Errorf("append transcript message %d: %w", i, err)
2875 }
2876 next = i + 1
2877 }
2878 cum := ag.Cost()
2879 if err := sess.AppendUsage(cum, cum); err != nil {
2880 return next, fmt.Errorf("append transcript usage: %w", err)
2881 }
2882 if err := sess.Flush(); err != nil {
2883 return next, fmt.Errorf("flush transcript: %w", err)
2884 }
2885 return next, nil
2886}
2887
2888func argsForStdin(rawArgs []string, mode os.FileMode) []string {
2889 if mode&os.ModeCharDevice != 0 {
2890 return rawArgs
2891 }
2892 return append([]string{"--print"}, rawArgs...)
2893}
2894
2895func combinePromptInput(stdin, positional string) string {
2896 parts := make([]string, 0, 2)
2897 if stdin = strings.TrimSpace(stdin); stdin != "" {
2898 parts = append(parts, stdin)
2899 }
2900 if positional = strings.TrimSpace(positional); positional != "" {
2901 parts = append(parts, positional)
2902 }
2903 return strings.Join(parts, "\n")
2904}
2905
2906func readAllStdin() (string, error) {
2907 fi, err := os.Stdin.Stat()
2908 if err != nil {
2909 return "", err
2910 }
2911 if (fi.Mode() & os.ModeCharDevice) != 0 {
2912 return "", nil
2913 }
2914 return readAllStdinFrom(os.Stdin)
2915}
2916
2917func readAllStdinFrom(r io.Reader) (string, error) {
2918 b, err := io.ReadAll(r)
2919 return string(b), err
2920}
2921
2922func printModels() {
2923 models := provider.Active()
2924
2925 // Compute column widths from actual data so wide providers (e.g.
2926 // xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2927 // `name` column off-screen. Floors mirror the historical layout so
2928 // short catalogs look the same as before.
2929 provW, idW, srcW := len("provider"), len("model id"), len("source")
2930 for _, m := range models {
2931 if w := len(m.Provider); w > provW {
2932 provW = w
2933 }
2934 if w := len(m.ID); w > idW {
2935 idW = w
2936 }
2937 source := m.Source
2938 if source == "" {
2939 source = "catalog"
2940 }
2941 if m.Speculative {
2942 source = "speculative"
2943 }
2944 if w := len(source); w > srcW {
2945 srcW = w
2946 }
2947 }
2948
2949 header := fmt.Sprintf("%-*s %-*s %8s %8s %s %-*s %s",
2950 provW, "provider",
2951 idW, "model id",
2952 "context", "max-out", "reasoning",
2953 srcW, "source",
2954 "name")
2955 fmt.Println(header)
2956
2957 for _, m := range models {
2958 reason := " "
2959 if m.Reasoning {
2960 reason = "✓"
2961 }
2962 source := m.Source
2963 if source == "" {
2964 source = "catalog"
2965 }
2966 if m.Speculative {
2967 source = "speculative"
2968 }
2969 fmt.Printf("%-*s %-*s %8d %8d %s %-*s %s\n",
2970 provW, m.Provider,
2971 idW, m.ID,
2972 m.ContextWindow, m.MaxOutput,
2973 reason,
2974 srcW, source,
2975 m.DisplayName)
2976 }
2977}