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