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