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" {
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 := runUpdateCommand(rawArgs, version); handled {
947 return err
948 }
949 if handled, err := runSessionsCommand(rawArgs); handled {
950 return err
951 }
952 if handled, err := runZutfileCommand(rawArgs, version); handled {
953 return err
954 }
955 return runWithArgsRaw(rawArgs, version)
956}
957
958func runWithArgsRaw(rawArgs []string, version string) error {
959 // `zut rpc` is shorthand for `zut --rpc` so third-party apps can
960 // spawn the binary with a clean argv. Strip the leading 'rpc'
961 // token and let the rest flow through the normal arg parser.
962 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
963 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
964 }
965
966 if fi, err := os.Stdin.Stat(); err == nil {
967 rawArgs = argsForStdin(rawArgs, fi.Mode())
968 }
969 args, err := ParseArgs(rawArgs)
970 if err != nil {
971 PrintHelp(version)
972 return err
973 }
974 if args.Help {
975 PrintHelp(version)
976 return nil
977 }
978 if args.Version {
979 fmt.Println("zut", version)
980 return nil
981 }
982 // Reject invalid orchestration combinations before catalog repair or model
983 // refresh can mutate local state or launch background work.
984 if err := validateOrchestrationArgs(args); err != nil {
985 return err
986 }
987 // Model catalog: load any cached discovery data before we inspect
988 // the model list (list-models, print/json, interactive).
989 userModels := LoadUserModels()
990 PrepareRuntimeCatalog(context.Background(), args.ListModels, args.Provider, args.APIKey, args.BaseURL, userModels, args.Model)
991
992 if args.ListModels {
993 printModels()
994 return nil
995 }
996
997 return runWithArgs(args, version)
998}
999
1000func runWithArgs(args Args, version string) error {
1001 if err := validateOrchestrationArgs(args); err != nil {
1002 return err
1003 }
1004 ctx := context.Background()
1005 switch args.Mode {
1006 case ModePrint:
1007 return runPrintMode(ctx, args, version)
1008 case ModeStream:
1009 return runStreamMode(ctx, args, version)
1010 case ModeJSON:
1011 return runJSONMode(ctx, args, version)
1012 case ModeRPC:
1013 return runRPCMode(ctx, args, version)
1014 default:
1015 return runInteractive(ctx, args, version)
1016 }
1017}
1018
1019// ---- print / json modes: require credentials, run single-shot ----
1020
1021// nonInteractiveExtHooks is the HostHooks impl used by print / json
1022// modes. They have no TUI, so notify / display go to stderr and
1023// submit / insert are no-ops (the extension can't steer a
1024// single-shot run once it's in flight anyway).
1025type nonInteractiveExtHooks struct{}
1026
1027func (nonInteractiveExtHooks) Notify(ext, level, message string) {
1028 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
1029}
1030func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
1031func (nonInteractiveExtHooks) Submit(string) {}
1032func (nonInteractiveExtHooks) SubmitSlash(string) {}
1033func (nonInteractiveExtHooks) Insert(string) {}
1034func (nonInteractiveExtHooks) Display(string, string) {}
1035func (nonInteractiveExtHooks) ClearNotes(string) {}
1036func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
1037func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
1038func (nonInteractiveExtHooks) ClosePanel(string, string) {}
1039func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
1040func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
1041func (nonInteractiveExtHooks) ClearWidget(string, string) {}
1042
1043// setupNonInteractiveExtensions loads --ext paths and (unless
1044// --no-ext) runs discovery. Returns the manager so the caller can
1045// wire tools into the resolved registry, and a cleanup closure to
1046// defer. Mirrors the interactive-mode setup minus the TUI hooks.
1047func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
1048 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
1049 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1050 fmt.Fprintln(os.Stderr, "extension load:", e)
1051 }
1052 if !args.NoExt {
1053 for _, e := range extMgr.Discover(ctx) {
1054 fmt.Fprintln(os.Stderr, "extension load:", e)
1055 }
1056 }
1057 extMgr.WaitForReady(3 * time.Second)
1058 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
1059 return extMgr, func() { extMgr.Stop(2 * time.Second) }
1060}
1061
1062// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
1063// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
1064// interactive path wires up, so extensions get their normal
1065// event-intercept surface in print / json / rpc flows too.
1066func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
1067 if ag == nil || extMgr == nil {
1068 return
1069 }
1070 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1071 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1072 if res.Block {
1073 return false, res.Reason, nil
1074 }
1075 return true, "", res.ModifiedArgs
1076 }
1077 ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1078 res := extMgr.InterceptTurnStart(turnCtx, step)
1079 return !res.Block, res.Reason, res.Context
1080 }
1081 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
1082 res := extMgr.InterceptAssistantMessage(ctx, text)
1083 if res.Block {
1084 return false, res.Reason, ""
1085 }
1086 return true, "", res.ReplaceText
1087 }
1088 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1089}
1090
1091type printStats struct {
1092 Provider string `json:"provider"`
1093 Model string `json:"model"`
1094 PromptTokens int `json:"prompt_tokens"`
1095 ReasoningTokens *int `json:"reasoning_tokens"`
1096 GeneratedOutputTokens int `json:"generated_output_tokens"`
1097 ElapsedMS int64 `json:"elapsed_ms"`
1098}
1099
1100func writePrintStats(path, providerID, model string, usage provider.Usage, elapsed time.Duration) error {
1101 var reasoning *int
1102 generated := usage.OutputTokens
1103 if usage.ReasoningTokensKnown {
1104 count := usage.ReasoningTokens
1105 reasoning = &count
1106 generated -= count
1107 if generated < 0 {
1108 generated = 0
1109 }
1110 }
1111 stats := printStats{
1112 Provider: providerID,
1113 Model: model,
1114 PromptTokens: usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens,
1115 ReasoningTokens: reasoning,
1116 GeneratedOutputTokens: generated,
1117 ElapsedMS: elapsed.Milliseconds(),
1118 }
1119 data, err := json.MarshalIndent(stats, "", " ")
1120 if err != nil {
1121 return fmt.Errorf("encode stats: %w", err)
1122 }
1123 data = append(data, '\n')
1124 if err := os.WriteFile(path, data, 0o644); err != nil {
1125 return fmt.Errorf("write stats %q: %w", path, err)
1126 }
1127 return nil
1128}
1129
1130func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
1131 if args.Orchestrate {
1132 return runOrchestratedPrintMode(ctx, args, version)
1133 }
1134 if args.NoYolo {
1135 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
1136 }
1137 r, err := Resolve(args, true)
1138 if err != nil {
1139 return err
1140 }
1141 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1142 defer stopExt()
1143
1144 ag := r.NewAgent()
1145 initialAg := ag
1146 defer func() {
1147 closeAgentLSP(ag)
1148 if ag != initialAg {
1149 closeAgentLSP(initialAg)
1150 }
1151 }()
1152 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1153 sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
1154 if err != nil {
1155 return err
1156 }
1157 if sess != nil {
1158 var providerName, model string
1159 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1160 if err != nil {
1161 return err
1162 }
1163 r.Provider, r.Model = providerName, model
1164 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1165 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1166 }
1167 announceSession(extMgr, sess)
1168 wireRetryLifecyclePersistence(ag, sess)
1169 if sess != nil {
1170 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1171 }
1172
1173 piped, _ := readAllStdin()
1174 prompt := combinePromptInput(piped, args.Prompt)
1175 if prompt == "" {
1176 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
1177 }
1178
1179 start := len(ag.Messages())
1180 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
1181 return err
1182 }
1183 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1184 return err
1185 }
1186 var persistCompaction func([]provider.Message) error
1187 if sess != nil {
1188 persistCompaction = sess.AppendCompaction
1189 }
1190 started := time.Now()
1191 prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
1192 usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1193 elapsed := time.Since(started)
1194 transcriptStart := start
1195 if recovery.Compacted {
1196 transcriptStart = recovery.OutputStart
1197 }
1198 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1199 if err != nil {
1200 return errors.Join(err, persistErr)
1201 }
1202 return persistErr
1203 }
1204 if err != nil {
1205 return err
1206 }
1207 if args.StatsPath != "" {
1208 return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
1209 }
1210 return nil
1211}
1212
1213func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1214 if args.Orchestrate {
1215 return runOrchestratedStreamMode(ctx, args, version)
1216 }
1217 if args.NoYolo {
1218 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
1219 }
1220 r, err := Resolve(args, true)
1221 if err != nil {
1222 return err
1223 }
1224 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1225 defer stopExt()
1226
1227 ag := r.NewAgent()
1228 initialAg := ag
1229 defer func() {
1230 closeAgentLSP(ag)
1231 if ag != initialAg {
1232 closeAgentLSP(initialAg)
1233 }
1234 }()
1235 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1236 sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
1237 if err != nil {
1238 return err
1239 }
1240 if sess != nil {
1241 var providerName, model string
1242 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1243 if err != nil {
1244 return err
1245 }
1246 r.Provider, r.Model = providerName, model
1247 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1248 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1249 }
1250 announceSession(extMgr, sess)
1251 wireRetryLifecyclePersistence(ag, sess)
1252 if sess != nil {
1253 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1254 }
1255
1256 piped, _ := readAllStdin()
1257 prompt := combinePromptInput(piped, args.Prompt)
1258 if prompt == "" {
1259 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1260 }
1261
1262 start := len(ag.Messages())
1263 preSink, finishPre := newStreamTextSink(os.Stdout)
1264 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1265 finishPre()
1266 return err
1267 }
1268 finishPre()
1269 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1270 return err
1271 }
1272 var persistCompaction func([]provider.Message) error
1273 if sess != nil {
1274 persistCompaction = sess.AppendCompaction
1275 }
1276 prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
1277 recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1278 transcriptStart := start
1279 if recovery.Compacted {
1280 transcriptStart = recovery.OutputStart
1281 }
1282 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1283 if err != nil {
1284 return errors.Join(err, persistErr)
1285 }
1286 return persistErr
1287 }
1288 return err
1289}
1290
1291func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1292 var streamed, wroteText, lastWasNL bool
1293 writeText := func(text string) {
1294 if text == "" {
1295 return
1296 }
1297 _, _ = fmt.Fprint(out, text)
1298 if syncer, ok := out.(interface{ Sync() error }); ok {
1299 _ = syncer.Sync()
1300 }
1301 wroteText = true
1302 lastWasNL = strings.HasSuffix(text, "\n")
1303 }
1304 sink := func(ev core.AgentEvent) {
1305 switch e := ev.(type) {
1306 case core.EvAssistantStart:
1307 streamed = false
1308 case core.EvTextDelta:
1309 streamed = true
1310 writeText(e.Delta)
1311 case core.EvAssistantMessage:
1312 if streamed {
1313 return
1314 }
1315 var text strings.Builder
1316 for _, content := range e.Message.Content {
1317 if block, ok := content.(provider.TextBlock); ok {
1318 if text.Len() > 0 {
1319 text.WriteString("\n")
1320 }
1321 text.WriteString(block.Text)
1322 }
1323 }
1324 writeText(text.String())
1325 }
1326 }
1327 finish := func() {
1328 if wroteText && !lastWasNL {
1329 writeText("\n")
1330 }
1331 }
1332 return sink, finish
1333}
1334
1335func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1336 if args.Orchestrate {
1337 return runOrchestratedJSONMode(ctx, args, version)
1338 }
1339 if args.NoYolo {
1340 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1341 }
1342 r, err := Resolve(args, true)
1343 if err != nil {
1344 return err
1345 }
1346 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1347 defer stopExt()
1348
1349 ag := r.NewAgent()
1350 initialAg := ag
1351 defer func() {
1352 closeAgentLSP(ag)
1353 if ag != initialAg {
1354 closeAgentLSP(initialAg)
1355 }
1356 }()
1357 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1358 sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
1359 if err != nil {
1360 return err
1361 }
1362 if sess != nil {
1363 var providerName, model string
1364 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1365 if err != nil {
1366 return err
1367 }
1368 r.Provider, r.Model = providerName, model
1369 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1370 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1371 }
1372 announceSession(extMgr, sess)
1373 wireRetryLifecyclePersistence(ag, sess)
1374 if sess != nil {
1375 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1376 }
1377
1378 piped, _ := readAllStdin()
1379 prompt := combinePromptInput(piped, args.Prompt)
1380 if prompt == "" {
1381 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1382 }
1383
1384 start := len(ag.Messages())
1385 enc := json.NewEncoder(os.Stdout)
1386 preSink := func(ev core.AgentEvent) {
1387 _ = enc.Encode(modes.EventToJSON(ev))
1388 }
1389 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1390 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1391 return err
1392 }
1393 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1394 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1395 return err
1396 }
1397 var persistCompaction func([]provider.Message) error
1398 if sess != nil {
1399 persistCompaction = sess.AppendCompaction
1400 }
1401 prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
1402 recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1403 transcriptStart := start
1404 if recovery.Compacted {
1405 transcriptStart = recovery.OutputStart
1406 }
1407 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1408 finalErr := persistErr
1409 if err != nil {
1410 finalErr = errors.Join(err, persistErr)
1411 }
1412 _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1413 return finalErr
1414 }
1415 return err
1416}
1417
1418// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1419// "!command" values execute via BashTool; other values are sent as an agent turn.
1420// shellOut receives live shell chunks (typically os.Stderr); sink receives
1421// agent events for plain-text pre (stream mode wires a live text sink).
1422func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1423 pre = strings.TrimSpace(pre)
1424 if pre == "" {
1425 return nil
1426 }
1427 if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1428 raw, err := json.Marshal(map[string]any{
1429 "command": cmd,
1430 "timeout": modes.ShellEscapeTimeoutSeconds,
1431 })
1432 if err != nil {
1433 return err
1434 }
1435 if shellOut != nil {
1436 fmt.Fprintf(shellOut, "$ %s\n", cmd)
1437 if f, ok := shellOut.(interface{ Sync() error }); ok {
1438 _ = f.Sync()
1439 }
1440 }
1441 progress := func(chunk string) {
1442 if shellOut == nil || chunk == "" {
1443 return
1444 }
1445 fmt.Fprint(shellOut, chunk)
1446 if f, ok := shellOut.(interface{ Sync() error }); ok {
1447 _ = f.Sync()
1448 }
1449 }
1450 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1451 res, err := bash.Execute(ctx, raw, progress)
1452 if err != nil {
1453 return fmt.Errorf("zutfile entry.pre: %w", err)
1454 }
1455 if res.IsError {
1456 var sb strings.Builder
1457 for _, c := range res.Content {
1458 if tb, ok := c.(provider.TextBlock); ok {
1459 sb.WriteString(tb.Text)
1460 }
1461 }
1462 msg := strings.TrimSpace(sb.String())
1463 if msg == "" {
1464 msg = "command failed"
1465 }
1466 return fmt.Errorf("zutfile entry.pre: %s", msg)
1467 }
1468 return nil
1469 }
1470 if ag == nil {
1471 return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1472 }
1473 if sink == nil {
1474 sink = func(core.AgentEvent) {}
1475 }
1476 return ag.Prompt(ctx, pre, nil, sink)
1477}
1478
1479var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1480
1481// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1482// skills and currently loaded extension tools) and updates the live
1483// agent's registry and system prompt. Used after /reload-ext and after
1484// zutfile entry.pre installs new skills or extensions.
1485// mutateRegistry, if non-nil, can inject session-specific tools (e.g. the subagent tool).
1486// interactive, when non-nil, serializes the final commit with agent replacement.
1487func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1488 if ag == nil {
1489 return subagents.WebSearchDeny, nil
1490 }
1491 var webSearchPolicyGeneration uint64
1492 if interactive != nil {
1493 // Capture before Resolve: the final commit must fail if settings,
1494 // Telegram attachment, or a newer registry commit changes policy while
1495 // filesystem/config/extension discovery is in flight.
1496 webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1497 }
1498 resolved, err := Resolve(args, true)
1499 if err != nil {
1500 return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1501 }
1502 if sharedSandbox != nil {
1503 resolved.UseSandbox(sharedSandbox)
1504 }
1505 if extToolAdapter != nil {
1506 resolved.MergeExtensionTools(extToolAdapter)
1507 }
1508 reg := resolved.ToolRegistry
1509 if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1510 tools.RemoveWebCapabilities(reg)
1511 }
1512 if mutateRegistry != nil {
1513 reg = mutateRegistry(reg)
1514 }
1515 if interactive != nil {
1516 oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1517 if !applied {
1518 _ = tools.CloseLSPManagers(reg)
1519 return subagents.WebSearchDeny, errInteractiveAgentChanged
1520 }
1521 interactive.DeferUntilIdle(func() {
1522 _ = tools.CloseLSPManagers(oldTools)
1523 })
1524 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1525 }
1526 oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1527 _ = tools.CloseLSPManagers(oldTools)
1528 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1529}
1530
1531func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1532 if policy != subagents.WebSearchAllow {
1533 return subagents.WebSearchDeny
1534 }
1535 for _, name := range tools.WebCapabilityNames {
1536 if _, ok := reg[name]; !ok {
1537 return subagents.WebSearchDeny
1538 }
1539 }
1540 return subagents.WebSearchAllow
1541}
1542
1543func webSearchToolAllowedForSession(args Args) bool {
1544 if args.NoTools || args.PermissionSet != nil {
1545 return false
1546 }
1547 if args.ToolsSet || len(args.Tools) > 0 {
1548 return toolListContains(args.Tools, "web_search")
1549 }
1550 return true
1551}
1552
1553func webSearchExplicitlyEnabledForSession(args Args) bool {
1554 return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1555}
1556
1557// reloadResourcesAfterStartupPre reloads extensions (if any) and
1558// refreshes the agent tool registry + system prompt so skills/extensions
1559// installed by entry.pre are visible to the following turn.
1560func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1561 _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1562 return err
1563}
1564
1565func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
1566 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1567 return subagents.WebSearchDeny, nil
1568 }
1569 adapter := &extToolAdapter{mgr: extMgr}
1570 if extMgr != nil {
1571 _ = extMgr.Reload(ctx, 2*time.Second)
1572 }
1573 return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1574}
1575
1576// ---- interactive mode: opens the TUI even without credentials ----
1577
1578func interactiveQuickModelShortcuts(shortcuts []QuickModelShortcut) []modes.QuickModelShortcut {
1579 result := make([]modes.QuickModelShortcut, len(shortcuts))
1580 for idx, s := range shortcuts {
1581 p := normalizedModelProfile(s)
1582 result[idx] = modes.QuickModelShortcut{Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning, FastMode: p.FastMode}
1583 }
1584 return result
1585}
1586
1587func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1588 initialCfg, _ := LoadConfig()
1589 if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 {
1590 return errors.New("goals.max_token_budget must be greater than zero")
1591 }
1592 // Resolve WITHOUT requiring credentials.
1593 r, err := Resolve(args, false)
1594 if err != nil {
1595 return err
1596 }
1597 // Resolve may have repaired a stale favorite. Use the saved repair for
1598 // the UI's active-profile comparison instead of the pre-resolution copy.
1599 if resolvedCfg, err := LoadConfig(); err == nil {
1600 initialCfg.QuickModelShortcuts = resolvedCfg.QuickModelShortcuts
1601 initialCfg.ActiveModelProfile = resolvedCfg.ActiveModelProfile
1602 }
1603
1604 authStore := AuthStoreFor()
1605 mgr := auth.NewManager(authStore)
1606 defer mgr.Close()
1607
1608 // Keep the sandbox pointer stable across agent rebuilds (login / model
1609 // switch). The Interactive UI toggles the lock via this pointer, and
1610 // rebuilt tool instances must share the same one so the lock sticks.
1611 sharedSandbox := r.Sandbox
1612
1613 // persistMu guards the active session and selection across the agent
1614 // loop, TUI callbacks, and session swaps. Declare it before tool
1615 // construction because subagent defaults must read the live selection too.
1616 var persistMu sync.Mutex
1617 // Serialize session transitions with persistence callbacks so a candidate
1618 // is read only after the old agent is flushed and cannot be overwritten by
1619 // an in-flight append before commit.
1620 var sessionTransitionMu sync.RWMutex
1621 activeProvider := r.Provider
1622 activeModel := r.Model
1623 var sess *core.Session
1624 scheduleEngine := scheduler.NewEngine(nil)
1625 var scheduledSessionMu sync.Mutex
1626 scheduledSessionLocks := make(map[string]*sync.Mutex)
1627 lockScheduledSession := func(sessionID string) func() {
1628 scheduledSessionMu.Lock()
1629 lock := scheduledSessionLocks[sessionID]
1630 if lock == nil {
1631 lock = new(sync.Mutex)
1632 scheduledSessionLocks[sessionID] = lock
1633 }
1634 scheduledSessionMu.Unlock()
1635 lock.Lock()
1636 return lock.Unlock
1637 }
1638
1639 // Build the extension manager BEFORE the agent so we can fold
1640 // extension-defined tools into the registry. Attach the interactive
1641 // host after constructing it below so startup alerts can be buffered.
1642 var iv *modes.Interactive
1643 extHooks := &interactiveExtHooks{}
1644 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1645 var startupExtensionErrors []string
1646 // --ext paths first so they win against installed extensions of
1647 // the same name (loadOne's first-write-wins semantics).
1648 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1649 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1650 }
1651 // --no-ext skips the global + project-local discovery scan;
1652 // explicit --ext paths above are still honoured so you can run
1653 // "only this extension" with --no-ext --ext ./x.
1654 if !args.NoExt {
1655 for _, e := range extMgr.Discover(ctx) {
1656 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1657 }
1658 }
1659 // Wait briefly for extensions to flush their initial register_tool
1660 // frames before we build the agent's tool registry. Half a second
1661 // is plenty for any extension that's actually well-behaved; ones
1662 // that don't send a ready frame eat the full grace and proceed.
1663 // 3s is the per-extension grace period for the ready frame.
1664 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1665 // from cold cache. The wait is tight only for extensions that
1666 // haven't sent ready by then; ones that signalled earlier release
1667 // the wait immediately.
1668 extMgr.WaitForReady(3 * time.Second)
1669 defer extMgr.Stop(2 * time.Second)
1670
1671 extToolAdapter := &extToolAdapter{mgr: extMgr}
1672 r.MergeExtensionTools(extToolAdapter)
1673 webSearchGuard := &webSearchSessionGuard{}
1674
1675 // Keep the shared subagent runtime in the outer scope: session reloads,
1676 // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1677 var runtime *subagentRuntime
1678 onResidentAccepted := func(spec subagents.ResidentChildSpec, turnID, _ string) {
1679 if iv != nil {
1680 iv.TrackResidentSubagent(spec.ID, turnID)
1681 }
1682 }
1683 onResidentCompletion := func(completion subagents.ResidentCompletion) bool {
1684 return iv != nil && iv.ReportResidentSubagent(completion)
1685 }
1686 activeProviderForSubagents := func() string {
1687 persistMu.Lock()
1688 defer persistMu.Unlock()
1689 return activeProvider
1690 }
1691 activeModelForSubagents := func() string {
1692 persistMu.Lock()
1693 defer persistMu.Unlock()
1694 return activeModel
1695 }
1696
1697 runtime = newSubagentRuntime(subagentRuntimeConfig{
1698 Args: args,
1699 Root: filepath.Join(ZutHome(), "subagents"),
1700 RepoRoot: r.CWD,
1701 Provider: r.Provider,
1702 Model: r.Model,
1703 Reasoning: r.Reasoning,
1704 BaseURL: r.BaseURL,
1705 InsecureTLS: r.InsecureTLS,
1706 FastMode: r.FastMode,
1707 ContextWindow: r.ContextWindow,
1708 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1709 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1710 WebSearchGuard: webSearchGuard,
1711 ActiveProvider: activeProviderForSubagents,
1712 ActiveModel: activeModelForSubagents,
1713 OnResidentSpawned: onResidentAccepted,
1714 ResidentCompletion: onResidentCompletion,
1715 })
1716 reloadDone := make(chan struct{})
1717 var reloadErrs []error
1718 if resident := runtime.ResidentManager(); resident != nil {
1719 // Resident reconciliation reads only the new journal format. It marks
1720 // active durable work interrupted and exposes it for an explicit new
1721 // prompt; it never constructs a child or replays an accepted task.
1722 go func() {
1723 reloadErrs = resident.Reconcile()
1724 close(reloadDone)
1725 }()
1726 } else {
1727 close(reloadDone)
1728 }
1729 defer func() {
1730 <-reloadDone
1731 _ = closeSubagentRuntimeFresh(runtime)
1732 }()
1733
1734 prepareInteractiveRegistry := runtime.PrepareRegistry
1735 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1736 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1737 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1738 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1739 runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1740 resolveSubagent := runtime.resolveSubagent
1741
1742 // Confirmation gate: when --no-yolo is on, the agent must ask
1743 // the user before every tool call. In interactive mode the TUI
1744 // provides the Confirmer; in print/json/rpc modes there's no
1745 // way to prompt, so the gate is constructed with a nil inner
1746 // which auto-refuses every call with a helpful reason.
1747 var confirmGate *core.ConfirmGate
1748 if args.NoYolo {
1749 confirmGate = core.NewConfirmGate(nil) // set below for interactive
1750 }
1751
1752 // Capture current args in a closure so BuildAgent can re-resolve
1753 // after a successful login (picks up the newly stored credential).
1754 wireAgentExt := func(a *core.Agent) *core.Agent {
1755 if a == nil {
1756 return a
1757 }
1758 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1759 // Guards run before confirmation because they may rewrite the
1760 // arguments. The user must approve the effective call that will
1761 // actually execute, not the model's original arguments.
1762 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1763 if r.Block {
1764 return false, r.Reason, nil
1765 }
1766 effectiveArgs := call.Arguments
1767 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1768 effectiveArgs = r.ModifiedArgs
1769 }
1770 if confirmGate != nil {
1771 var content strings.Builder
1772 // The remembered "always allow" grant is keyed by tool name,
1773 // or a finer per-action key when the tool asks for one (the
1774 // subagent facade). Compute it from the effective args so a
1775 // grant matches the call that will actually run.
1776 _, currentTools := a.PromptConfig()
1777 confirmationKey := confirmationKeyFor(currentTools, call.Name, effectiveArgs)
1778 if tool, err := currentTools.Get(call.Name); err == nil {
1779 if previewer, ok := tool.(core.ToolPreviewer); ok {
1780 preview, err := previewer.Preview(ctx, effectiveArgs)
1781 if err != nil {
1782 return false, err.Error(), nil
1783 }
1784 for _, block := range preview.Content {
1785 if text, ok := block.(provider.TextBlock); ok {
1786 if content.Len() > 0 {
1787 content.WriteString("\n")
1788 }
1789 content.WriteString(text.Text)
1790 }
1791 }
1792 }
1793 }
1794 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1795 ID: call.ID,
1796 Name: call.Name,
1797 Key: confirmationKey,
1798 Summary: core.BuildPreview(effectiveArgs, 120),
1799 Content: content.String(),
1800 Origin: call.Origin,
1801 })
1802 if !ok {
1803 return false, reason, nil
1804 }
1805 }
1806 return true, "", r.ModifiedArgs
1807 }
1808 a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1809 r := extMgr.InterceptTurnStart(turnCtx, step)
1810 return !r.Block, r.Reason, r.Context
1811 }
1812 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1813 r := extMgr.InterceptAssistantMessage(ctx, text)
1814 if r.Block {
1815 return false, r.Reason, ""
1816 }
1817 return true, "", r.ReplaceText
1818 }
1819 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1820 runtime.WireRequiredWorkerGate(a)
1821 return a
1822 }
1823
1824 scheduleTool := &tools.ScheduleTool{
1825 Engine: scheduleEngine,
1826 SessionID: func() string {
1827 persistMu.Lock()
1828 defer persistMu.Unlock()
1829 if sess == nil {
1830 return ""
1831 }
1832 return sess.ID
1833 },
1834 Location: func() *time.Location { return time.Local },
1835 }
1836 schedulerAllowed := !args.NoTools && (!args.ToolsSet && len(args.Tools) == 0 || toolListContains(args.Tools, tools.ScheduleToolName))
1837 installSchedulerTool := func(registry core.Registry) core.Registry {
1838 if schedulerAllowed {
1839 registry[tools.ScheduleToolName] = scheduleTool
1840 }
1841 return registry
1842 }
1843 installSchedulerTool(r.ToolRegistry)
1844 prepareInteractiveRegistryWithScheduler := func(registry core.Registry) core.Registry {
1845 return prepareInteractiveRegistry(installSchedulerTool(registry))
1846 }
1847
1848 var ag *core.Agent
1849 buildAgent := func() (*core.Agent, string, string, error) {
1850 resolved, err := Resolve(args, true)
1851 if err != nil {
1852 return nil, "", "", err
1853 }
1854 resolved.UseSandbox(sharedSandbox)
1855 runtime.SetProvider(resolved.Provider)
1856 runtime.SetModel(resolved.Model)
1857 runtime.SetContextWindow(resolved.ContextWindow)
1858 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1859 runtime.SetFastMode(resolved.FastMode)
1860 resolved.MergeExtensionTools(extToolAdapter)
1861 installSchedulerTool(resolved.ToolRegistry)
1862 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1863 return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1864 }
1865
1866 // Rebuild agent with an explicit provider/model override.
1867 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1868 next := args
1869 if providerOverride != "" {
1870 next.Provider = providerOverride
1871 }
1872 if modelOverride != "" {
1873 next.Model = modelOverride
1874 }
1875 resolved, err := Resolve(next, true)
1876 if err != nil {
1877 return nil, "", "", err
1878 }
1879 resolved.UseSandbox(sharedSandbox)
1880 runtime.SetProvider(resolved.Provider)
1881 runtime.SetModel(resolved.Model)
1882 runtime.SetContextWindow(resolved.ContextWindow)
1883 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1884 runtime.SetFastMode(resolved.FastMode)
1885 resolved.MergeExtensionTools(extToolAdapter)
1886 installSchedulerTool(resolved.ToolRegistry)
1887 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1888 return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1889 }
1890
1891 // Rebuild agent for the rescue picker after a recoverable failure.
1892 // Unlike buildAgentFor, this drops launch-time --api-key and
1893 // --base-url overrides because those are typically the cause of the
1894 // rescue (a bad key, a typo'd base URL, or a corporate gateway that
1895 // only the originally-picked provider needed). Re-resolving without
1896 // them lets the rescue retry use env vars / auth.json / provider
1897 // defaults the way zut would have without the overrides.
1898 buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1899 next := args
1900 next.APIKey = ""
1901 next.BaseURL = ""
1902 if providerOverride != "" {
1903 next.Provider = providerOverride
1904 }
1905 if modelOverride != "" {
1906 next.Model = modelOverride
1907 }
1908 resolved, err := Resolve(next, true)
1909 if err != nil {
1910 return nil, "", "", err
1911 }
1912 resolved.UseSandbox(sharedSandbox)
1913 runtime.SetProvider(resolved.Provider)
1914 runtime.SetModel(resolved.Model)
1915 runtime.SetContextWindow(resolved.ContextWindow)
1916 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1917 runtime.SetFastMode(resolved.FastMode)
1918 resolved.MergeExtensionTools(extToolAdapter)
1919 installSchedulerTool(resolved.ToolRegistry)
1920 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1921 return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1922 }
1923
1924 if r.HasCredential() {
1925 ag = wireAgentExt(r.newInteractiveAgent())
1926 }
1927
1928 // /reload-ext callback: after the manager has respawned every
1929 // extension, re-resolve the tool registry (built-ins + freshly-
1930 // registered extension tools) and swap it onto the current
1931 // agent in-place. The current agent may have been replaced by a
1932 // /model swap since spawn, so re-read the live `ag` on each
1933 // invocation.
1934 extMgr.SetOnReload(func() {
1935 current := liveInteractiveAgent(iv, ag)
1936 if current == nil {
1937 return
1938 }
1939 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil && iv != nil {
1940 iv.ReportError(err)
1941 }
1942 })
1943
1944 freshSession := true
1945 var sessBaselineMsgs int // messages already on disk when current session opened
1946 var sessionTitlePending bool
1947 // initialPlanSnapshots is reconstructed from the untrimmed resumed
1948 // transcript. The interactive seeds from it because the live agent only
1949 // receives the trim window after a resume.
1950 var initialPlanSnapshots map[string]core.PlanUpdate
1951 if !args.NoSess && ag != nil {
1952 var sessErr error
1953 sess, freshSession, sessErr = openOrCreateSessionState(ctx, args, r, ag, version)
1954 if sessErr != nil {
1955 return sessErr
1956 }
1957 sessBaselineMsgs = len(ag.Messages())
1958 if sess != nil {
1959 // Startup resume uses the same provider/model-aware preparation as
1960 // the interactive session picker. This matters for --continue,
1961 // --resume, and explicit --session paths whose latest meta row was
1962 // written by another provider/model.
1963 candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1964 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1965 if resumeErr != nil {
1966 return resumeErr
1967 }
1968 sess = candidate.session
1969 ag = candidate.agent
1970 sessBaselineMsgs = candidate.fullMessageCount
1971 initialPlanSnapshots = candidate.planSnapshots
1972 activeProvider = candidate.provider
1973 activeModel = candidate.model
1974 r.Provider = candidate.provider
1975 r.Model = candidate.model
1976 }
1977 if sess != nil && ag != nil {
1978 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1979 }
1980 }
1981 announceSession(extMgr, sess)
1982 // Print the hint after the session close defer below and all terminal/TUI
1983 // cleanup defers. Close removes a fresh empty session, so only a session
1984 // that remains discoverable through the hinted command gets a hint.
1985 defer func() {
1986 if !iv.ExitedViaCtrlC() {
1987 return
1988 }
1989 persistMu.Lock()
1990 current := sess
1991 persistMu.Unlock()
1992 hasMessages := false
1993 if finalAg := iv.Agent(); finalAg != nil {
1994 hasMessages = finalAg.MessageCount() > 0
1995 }
1996 if resumeID := resumableSessionID(ZutHome(), current, hasMessages); resumeID != "" {
1997 fmt.Print(resumeSessionHint(resumeID))
1998 }
1999 }()
2000 defer func() {
2001 persistMu.Lock()
2002 defer persistMu.Unlock()
2003 runErr = joinSessionCloseError(runErr, sess)
2004 }()
2005
2006 // persistMessage is the per-message hook bound to the agent. It
2007 // appends each new transcript message to the live session as soon
2008 // as it lands, so a kill / closed terminal / OS crash costs at
2009 // most the in-flight turn instead of the whole session. The
2010 // baseline counter advances in lock-step so the exit-time flush
2011 // doesn't double-write rows already on disk.
2012 persistMessage := func(m provider.Message) {
2013 sessionTransitionMu.RLock()
2014 defer sessionTransitionMu.RUnlock()
2015 persistMu.Lock()
2016 defer persistMu.Unlock()
2017 if sess == nil {
2018 return
2019 }
2020 if err := sess.AppendMessage(m); err == nil {
2021 sessBaselineMsgs++
2022 }
2023 }
2024 persistUsage := func(cum provider.Usage) {
2025 sessionTransitionMu.RLock()
2026 defer sessionTransitionMu.RUnlock()
2027 persistMu.Lock()
2028 defer persistMu.Unlock()
2029 if sess == nil {
2030 return
2031 }
2032 _ = sess.AppendUsage(cum, cum)
2033 }
2034 persistRetryLifecycle := func(record core.RetryLifecycleRecord) {
2035 sessionTransitionMu.RLock()
2036 defer sessionTransitionMu.RUnlock()
2037 persistMu.Lock()
2038 defer persistMu.Unlock()
2039 if sess != nil {
2040 sess.RecordRetryLifecycle(record)
2041 }
2042 }
2043 persistCompaction := func(messages []provider.Message) {
2044 sessionTransitionMu.RLock()
2045 var session *extproto.SessionContext
2046 var states map[string]json.RawMessage
2047 persistMu.Lock()
2048 if sess != nil {
2049 if err := sess.AppendCompaction(messages); err == nil {
2050 sessBaselineMsgs = len(messages)
2051 session = sessionContext(sess)
2052 states = copyExtensionStates(sess.ExtensionState)
2053 }
2054 }
2055 persistMu.Unlock()
2056 sessionTransitionMu.RUnlock()
2057 if session != nil {
2058 extMgr.EmitSessionEvent("session_compacted", session, states)
2059 }
2060 }
2061 persistCompactHandoff := func(state json.RawMessage) error {
2062 sessionTransitionMu.RLock()
2063 defer sessionTransitionMu.RUnlock()
2064 persistMu.Lock()
2065 defer persistMu.Unlock()
2066 if sess == nil {
2067 return nil
2068 }
2069 return sess.UpdateCompactHandoff(state)
2070 }
2071 currentCompactHandoff := func() json.RawMessage {
2072 persistMu.Lock()
2073 defer persistMu.Unlock()
2074 if sess == nil {
2075 return nil
2076 }
2077 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
2078 }
2079 persistGoal := func(goal *core.SessionGoal) error {
2080 sessionTransitionMu.RLock()
2081 defer sessionTransitionMu.RUnlock()
2082 persistMu.Lock()
2083 defer persistMu.Unlock()
2084 if sess == nil {
2085 return errors.New("session persistence is disabled")
2086 }
2087 return sess.UpdateGoal(goal)
2088 }
2089 persistGoalRuntime := func(goal *core.SessionGoal) error {
2090 sessionTransitionMu.RLock()
2091 defer sessionTransitionMu.RUnlock()
2092 persistMu.Lock()
2093 defer persistMu.Unlock()
2094 if sess == nil {
2095 return errors.New("session persistence is disabled")
2096 }
2097 return sess.UpdateGoalRuntime(goal)
2098 }
2099 ensureMission := func(objective string) error {
2100 sessionTransitionMu.RLock()
2101 defer sessionTransitionMu.RUnlock()
2102 persistMu.Lock()
2103 defer persistMu.Unlock()
2104 if sess == nil {
2105 return errors.New("session persistence is disabled")
2106 }
2107 return sess.EnsureMission(objective, core.MissionSourceUser)
2108 }
2109 currentGoal := func() *core.SessionGoal {
2110 persistMu.Lock()
2111 defer persistMu.Unlock()
2112 if sess == nil || sess.Meta.Goal == nil {
2113 return nil
2114 }
2115 goal := *sess.Meta.Goal
2116 return &goal
2117 }
2118 currentGoalHistory := func() []core.SessionGoal {
2119 persistMu.Lock()
2120 defer persistMu.Unlock()
2121 if sess == nil {
2122 return nil
2123 }
2124 return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...)
2125 }
2126 currentPlan := func() []core.PlanStep {
2127 return liveInteractiveAgent(iv, ag).CurrentPlan()
2128 }
2129 persistedPlan := func() *core.SessionPlan {
2130 persistMu.Lock()
2131 defer persistMu.Unlock()
2132 if sess == nil || sess.Meta.Plan == nil {
2133 return nil
2134 }
2135 plan := *sess.Meta.Plan
2136 plan.Steps = append([]core.PlanStep(nil), sess.Meta.Plan.Steps...)
2137 return &plan
2138 }
2139 persistPlan := func(plan *core.SessionPlan) error {
2140 sessionTransitionMu.RLock()
2141 defer sessionTransitionMu.RUnlock()
2142 persistMu.Lock()
2143 defer persistMu.Unlock()
2144 if sess == nil {
2145 return errors.New("session persistence is disabled")
2146 }
2147 return sess.UpdatePlan(plan)
2148 }
2149 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
2150 if a == nil {
2151 return
2152 }
2153 a.CommitToolResult = func(_ string, result core.ToolResult) error {
2154 persistMu.Lock()
2155 defer persistMu.Unlock()
2156 return persistToolResultState(extMgr, boundSession, result, initialCfg.Goals.MaxTokenBudget)
2157 }
2158 }
2159 wireAgentPersist := func(a *core.Agent) *core.Agent {
2160 if a == nil {
2161 return a
2162 }
2163 persistMu.Lock()
2164 currentSession := sess
2165 persistMu.Unlock()
2166 if currentSession != nil {
2167 a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
2168 }
2169 a.OnMessageAppended = persistMessage
2170 a.OnUsage = persistUsage
2171 a.OnRetryLifecycle = persistRetryLifecycle
2172 a.OnTranscriptCompacted = persistCompaction
2173 bindAgentToolResultSession(a, currentSession)
2174 return a
2175 }
2176 wireAgentPersist(ag)
2177 defer func() {
2178 closeAgentLSP(liveInteractiveAgent(iv, ag))
2179 closeResolvedLSP(r)
2180 }()
2181
2182 // Re-wrap the build closures so any agent constructed by the TUI
2183 // (login, /model swap to a different provider) also gets the
2184 // persistence hooks. Without this, switching provider would
2185 // silently revert to the old in-memory-only behaviour. Selection state
2186 // is published by the successful transition itself, not by preparation.
2187 baseBuildAgent := buildAgent
2188 buildAgent = func() (*core.Agent, string, string, error) {
2189 a, p, m, err := baseBuildAgent()
2190 return wireAgentPersist(a), p, m, err
2191 }
2192 baseBuildAgentFor := buildAgentFor
2193 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
2194 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
2195 return wireAgentPersist(a), p, m, err
2196 }
2197 baseBuildAgentForRescue := buildAgentForRescue
2198 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
2199 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
2200 return wireAgentPersist(a), p, m, err
2201 }
2202
2203 // loadSession replaces the current session with the one at path and
2204 // hands its messages to the agent. Used by the /sessions picker.
2205 loadSessionContext := func(loadCtx context.Context, path string) (loadErr error) {
2206 if err := loadCtx.Err(); err != nil {
2207 return err
2208 }
2209 selectedMeta, err := core.ManagedSessionMeta(loadCtx, agentSessionsRoot(ZutHome(), args), path)
2210 if err != nil {
2211 return fmt.Errorf("validate selected session: %w", err)
2212 }
2213 if info, statErr := os.Stat(selectedMeta.CWD); statErr != nil || !info.IsDir() {
2214 if statErr != nil {
2215 return fmt.Errorf("selected session cwd: %w", statErr)
2216 }
2217 return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)
2218 }
2219 // Hold the transition lock from the pre-flush through the commit.
2220 // Persistence callbacks take the read side, so an active session
2221 // cannot be snapshotted before its lazy writes land or overwritten by
2222 // a callback while the candidate is being installed.
2223 if err := loadCtx.Err(); err != nil {
2224 return err
2225 }
2226 sessionTransitionMu.Lock()
2227 defer sessionTransitionMu.Unlock()
2228 if err := loadCtx.Err(); err != nil {
2229 return err
2230 }
2231 workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2232
2233 currentAg := liveInteractiveAgent(iv, ag)
2234 if currentAg == nil {
2235 return fmt.Errorf("no agent running; log in first")
2236 }
2237
2238 persistMu.Lock()
2239 currentProvider := activeProvider
2240 currentModel := activeModel
2241 oldSess := sess
2242 // Flush before reading the candidate. Use the locked form rather
2243 // than the public callback, which would acquire the read lock held
2244 // by this transition.
2245 if oldSess != nil {
2246 next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
2247 sessBaselineMsgs = next
2248 if flushErr != nil {
2249 persistMu.Unlock()
2250 return fmt.Errorf("flush current session: %w", flushErr)
2251 }
2252 }
2253 currentMessageCount := len(currentAg.Messages())
2254 currentCost := currentAg.Cost()
2255 persistMu.Unlock()
2256
2257 // A candidate build must not publish its provider/model as the live
2258 // selection before the candidate commits. The builder also stages
2259 // provider settings on the shared runtime, so restore those settings on
2260 // every failed path, including errors returned during preparation.
2261 runtimeConfig := runtime.snapshotConfiguration()
2262 oldCWD := args.CWD
2263 oldResolvedCWD := r.CWD
2264 oldPermissionSet := args.PermissionSet
2265 oldSandboxRoot := ""
2266 oldSandboxLocked := false
2267 if sharedSandbox != nil {
2268 oldSandboxRoot = sharedSandbox.Root
2269 oldSandboxLocked = sharedSandbox.Locked()
2270 }
2271 rollbackWorkspace := func() {
2272 if !workspaceChanged {
2273 return
2274 }
2275 args.CWD = oldCWD
2276 r.CWD = oldResolvedCWD
2277 args.PermissionSet = oldPermissionSet
2278 runtime.SetRepoRoot(oldResolvedCWD)
2279 if sharedSandbox != nil {
2280 sharedSandbox.Root = oldSandboxRoot
2281 if oldSandboxLocked {
2282 sharedSandbox.Lock()
2283 } else {
2284 sharedSandbox.Unlock()
2285 }
2286 }
2287 }
2288 if workspaceChanged {
2289 args.CWD = selectedMeta.CWD
2290 r.CWD = selectedMeta.CWD
2291 runtime.SetRepoRoot(selectedMeta.CWD)
2292 if args.PermissionSet != nil {
2293 expanded := args.PermissionSet.Expand(selectedMeta.CWD, args.AgentDataDir)
2294 args.PermissionSet = &expanded
2295 }
2296 if sharedSandbox != nil {
2297 sharedSandbox.Root = selectedMeta.CWD
2298 if oldSandboxLocked {
2299 sharedSandbox.Lock()
2300 } else {
2301 sharedSandbox.Unlock()
2302 }
2303 }
2304 }
2305 committed := false
2306 var candidate sessionResumeCandidate
2307 defer func() {
2308 if !committed {
2309 rollbackWorkspace()
2310 runtime.restoreConfiguration(runtimeConfig)
2311 loadErr = joinSessionCloseError(loadErr, candidate.session)
2312 }
2313 }()
2314 if workspaceChanged {
2315 candidate, err = prepareSessionResumeForWorkspace(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2316 } else {
2317 candidate, err = prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2318 }
2319 if err != nil {
2320 // prepareSessionResume closes its append handle on failure;
2321 // no live agent or session has been changed.
2322 return err
2323 }
2324
2325 // Wire the private candidate before it can become visible. This is
2326 // also required for legacy same-provider resumes, where the current
2327 // agent is reused and only its transcript/usage are refreshed.
2328 wireAgentPersist(candidate.agent)
2329
2330 if err := loadCtx.Err(); err != nil {
2331 return err
2332 }
2333 persistMu.Lock()
2334 // A provider rebuild ran outside persistMu. Refuse to install it if
2335 // the live agent, session, selection, transcript, or cost changed in
2336 // the meantime; otherwise a slow resume could discard a newer turn or
2337 // model swap.
2338 live := liveInteractiveAgent(iv, ag)
2339 changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
2340 if !changed && live != nil {
2341 changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
2342 }
2343 if changed {
2344 persistMu.Unlock()
2345 return fmt.Errorf("session changed while loading; please try again")
2346 }
2347 newStates := copyExtensionStates(candidate.session.ExtensionState)
2348 var oldCloseErr error
2349 if oldSess != nil {
2350 oldCloseErr = oldSess.Close()
2351 }
2352 sess = candidate.session
2353 bindAgentToolResultSession(candidate.agent, candidate.session)
2354 // Seed core plan state from the loaded session before the swap publishes
2355 // the agent; see applySessionResume for why this is required on resume.
2356 candidate.agent.SetPlan(candidate.session.Meta.PlanSteps())
2357 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
2358 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
2359 // The live agent only receives a compact resume window, but the
2360 // session file remains intact. Keep the persistence baseline at
2361 // the original on-disk message count so future turns append after
2362 // the full session instead of duplicating the hydrated tail.
2363 sessBaselineMsgs = candidate.fullMessageCount
2364
2365 if candidate.rebuilt {
2366 // Commit the TUI agent while persistMu still protects the session
2367 // writer, so no new message can land on the old file between the
2368 // two state changes.
2369 if iv != nil {
2370 if workspaceChanged {
2371 iv.ApplySessionAgentWithCWD(candidate.agent, candidate.provider, candidate.model, selectedMeta.CWD, candidate.session.Meta.CompactHandoff)
2372 } else {
2373 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2374 }
2375 }
2376 ag = candidate.agent
2377 } else {
2378 currentAg.SetMessages(candidate.messages)
2379 currentAg.ResetRepetitionGuard()
2380 currentAg.SeedCost(candidate.cumulative)
2381 currentAg.SeedLastTurnUsage(candidate.lastTurn)
2382 ag = currentAg
2383 }
2384 activeProvider = candidate.provider
2385 activeModel = candidate.model
2386 if candidate.session != nil {
2387 // Keep the resident parent's session association in the same commit
2388 // as the session, agent, usage, and persistence baseline.
2389 runtime.SetActiveSession(candidate.session.ID)
2390 if iv != nil {
2391 iv.SetSubagentSessionScope(candidate.session.ID)
2392 }
2393 }
2394 committed = true
2395 persistMu.Unlock()
2396 if iv != nil {
2397 iv.SetPinnedSkillsPending(false)
2398 iv.RefreshGoal()
2399 iv.RefreshPlan(candidate.planSnapshots)
2400 }
2401 if candidate.session.Meta.Parent != "" {
2402 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
2403 }
2404 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
2405 if oldCloseErr != nil {
2406 return fmt.Errorf("close current session: %w", oldCloseErr)
2407 }
2408 return nil
2409 }
2410 loadSession := func(path string) error {
2411 return loadSessionContext(context.Background(), path)
2412 }
2413
2414 // changeCWD switches the running session to a new working directory.
2415 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
2416 // /cd slash command (which itself is only fired by the workspaces
2417 // extension's panel-key Enter handler today; the user can type /cd
2418 // directly but it's not in autocomplete / help / the README).
2419 //
2420 // Steps, in order:
2421 // 1. resolve + validate the new path (~ expansion, abs/rel)
2422 // 2. stage the candidate cwd, sandbox, and subagent state
2423 // 3. rebuild the agent and allocate its new session
2424 // 4. commit all new state together, closing the old session last
2425 // 5. push the committed state into the running Interactive
2426 //
2427 // The /jail state is preserved verbatim: if the sandbox was locked
2428 // to the old cwd, it stays locked, just re-pointed at the new one.
2429 changeCWD := func(path string) error {
2430 sessionTransitionMu.Lock()
2431 defer sessionTransitionMu.Unlock()
2432 if path == "" {
2433 return fmt.Errorf("empty path")
2434 }
2435 // ~ expansion.
2436 if path == "~" || strings.HasPrefix(path, "~/") {
2437 home, herr := os.UserHomeDir()
2438 if herr != nil || home == "" {
2439 return fmt.Errorf("cannot expand ~: %v", herr)
2440 }
2441 if path == "~" {
2442 path = home
2443 } else {
2444 path = filepath.Join(home, path[2:])
2445 }
2446 }
2447 if !filepath.IsAbs(path) {
2448 path = filepath.Join(args.CWD, path)
2449 }
2450 absPath, err := filepath.Abs(path)
2451 if err != nil {
2452 return err
2453 }
2454 info, err := os.Stat(absPath)
2455 if err != nil {
2456 return err
2457 }
2458 if !info.IsDir() {
2459 return fmt.Errorf("not a directory: %s", absPath)
2460 }
2461
2462 currentAg := liveInteractiveAgent(iv, ag)
2463 if currentAg == nil {
2464 return fmt.Errorf("no agent running; log in first")
2465 }
2466
2467 // Stage the candidate state while retaining a complete rollback
2468 // snapshot. A failed rebuild or session allocation must leave the
2469 // current agent, session, sandbox, and subagent root usable.
2470 oldCWD := args.CWD
2471 oldResolvedCWD := r.CWD
2472 oldPermissionSet := args.PermissionSet
2473 persistMu.Lock()
2474 oldActiveProvider := activeProvider
2475 oldActiveModel := activeModel
2476 persistMu.Unlock()
2477 runtimeConfig := runtime.snapshotConfiguration()
2478 oldSandboxRoot := ""
2479 oldSandboxLocked := false
2480 if sharedSandbox != nil {
2481 oldSandboxRoot = sharedSandbox.Root
2482 oldSandboxLocked = sharedSandbox.Locked()
2483 }
2484 rollback := func() {
2485 args.CWD = oldCWD
2486 r.CWD = oldResolvedCWD
2487 args.PermissionSet = oldPermissionSet
2488 persistMu.Lock()
2489 activeProvider = oldActiveProvider
2490 activeModel = oldActiveModel
2491 persistMu.Unlock()
2492 runtime.restoreConfiguration(runtimeConfig)
2493 if sharedSandbox != nil {
2494 sharedSandbox.Root = oldSandboxRoot
2495 if oldSandboxLocked {
2496 sharedSandbox.Lock()
2497 } else {
2498 sharedSandbox.Unlock()
2499 }
2500 }
2501 }
2502
2503 args.CWD = absPath
2504 r.CWD = absPath
2505 runtime.SetRepoRoot(absPath)
2506 if args.PermissionSet != nil {
2507 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2508 args.PermissionSet = &expanded
2509 }
2510 if sharedSandbox != nil {
2511 sharedSandbox.Root = absPath
2512 if oldSandboxLocked {
2513 sharedSandbox.Lock()
2514 } else {
2515 sharedSandbox.Unlock()
2516 }
2517 }
2518
2519 // Rebuild the agent so tools / AGENTS.md / system prompt
2520 // re-bind to the new cwd. buildAgent() reads from args + r.
2521 newAg, newProvider, newModel, berr := buildAgent()
2522 if berr != nil {
2523 rollback()
2524 return fmt.Errorf("rebuild agent: %w", berr)
2525 }
2526
2527 // Fresh session in the new cwd's bucket. We bypass
2528 // openOrCreateSession's --continue / --resume branches
2529 // because /cd's semantics are "start fresh here", matching
2530 // what relaunching `zut --cwd <path>` would do today.
2531 var newSess *core.Session
2532 if !args.NoSess {
2533 newRoot := agentSessionsRoot(ZutHome(), args)
2534 core.PruneEmptySessions(newRoot, absPath)
2535 var serr error
2536 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2537 if serr != nil {
2538 rollback()
2539 return fmt.Errorf("open session in %s: %w", absPath, serr)
2540 }
2541 }
2542
2543 // Invalidate any title request before swapping the host-owned session
2544 // pointer. The interactive state reset below happens after the swap,
2545 // so this guard closes the window where a late result could otherwise
2546 // be written to the new session.
2547 if iv != nil {
2548 iv.CancelPendingSessionTitle()
2549 }
2550
2551 // Commit only after every fallible operation has succeeded. Close
2552 // the old session last so rollback never has to resurrect it.
2553 var newStates map[string]json.RawMessage
2554 var oldCloseErr error
2555 persistMu.Lock()
2556 if sess != nil {
2557 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2558 sessBaselineMsgs = next
2559 if flushErr != nil {
2560 persistMu.Unlock()
2561 rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2562 rollback()
2563 return rollbackErr
2564 }
2565 oldCloseErr = sess.Close()
2566 }
2567 sess = newSess
2568 bindAgentToolResultSession(newAg, newSess)
2569 if newSess != nil {
2570 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2571 newStates = copyExtensionStates(newSess.ExtensionState)
2572 }
2573 sessBaselineMsgs = 0
2574 sessionTitlePending = false
2575 activeProvider = newProvider
2576 activeModel = newModel
2577 persistMu.Unlock()
2578 ag = newAg
2579 closeAgentLSP(currentAg)
2580
2581 // Push the new state into the running Interactive.
2582 if iv != nil {
2583 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2584 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2585 iv.RefreshGoal()
2586 // A new cwd starts a fresh session with no plan; reset the counters
2587 // and snapshot map so the previous workspace's checklist is gone.
2588 iv.RefreshPlan(nil)
2589 }
2590
2591 // Re-scope the subagent dashboard to the new session.
2592 if newSess != nil {
2593 runtime.SetActiveSession(newSess.ID)
2594 if iv != nil {
2595 iv.SetSubagentSessionScope(newSess.ID)
2596 }
2597 }
2598 if newSess != nil {
2599 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2600 }
2601 if oldCloseErr != nil {
2602 return fmt.Errorf("close current session: %w", oldCloseErr)
2603 }
2604 return nil
2605 }
2606
2607 devBuild := isDevVersion(version)
2608 if devBuild {
2609 fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2610 }
2611 term := tui.NewProcTerm()
2612
2613 var updateCh chan modes.UpdateInfo
2614 var changelogCh chan modes.ChangelogPayload
2615 if !devBuild {
2616 // Kick off the async update check so the banner can appear when the
2617 // http response eventually arrives (usually <1s on cached DNS). Map
2618 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2619 updateCh = make(chan modes.UpdateInfo, 1)
2620 go func() {
2621 defer close(updateCh)
2622 src := <-CheckForUpdateAsync(ZutHome(), version)
2623 updateCh <- modes.UpdateInfo{
2624 Current: src.Current,
2625 Latest: src.Latest,
2626 Available: src.Available,
2627 URL: src.URL,
2628 }
2629 }()
2630
2631 // Changelog: when the running version differs from the last
2632 // version whose release notes the user dismissed, fetch the
2633 // release body from GitHub and have the TUI show it once. On
2634 // first-ever launch (no prior LastChangelogShown), seed the
2635 // stored version silently — don't dump release notes at someone
2636 // who just installed.
2637 changelogCh = make(chan modes.ChangelogPayload, 1)
2638 go func() {
2639 defer close(changelogCh)
2640 cfg, _ := LoadConfig()
2641 if cfg.LastChangelogShown == "" {
2642 SeedChangelogVersion(version)
2643 return
2644 }
2645 if !ShouldShowChangelog(version, cfg) {
2646 return
2647 }
2648 info := <-FetchChangelogAsync(version)
2649 if info.Body == "" {
2650 return
2651 }
2652 changelogCh <- modes.ChangelogPayload{
2653 Version: info.Version,
2654 Body: info.Body,
2655 URL: info.URL,
2656 }
2657 }()
2658 }
2659
2660 fastMode := r.FastMode
2661 quickModelShortcuts := interactiveQuickModelShortcuts(initialCfg.QuickModelShortcuts)
2662 themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
2663 if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
2664 fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv)
2665 themeEnv = ""
2666 }
2667 themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv)
2668 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
2669 if themeErr != nil {
2670 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2671 if !themePreference.Forced {
2672 themePreference = tui.ResolveThemePreference("", themeEnv)
2673 if initialCfg.Theme != "" {
2674 initialCfg.Theme = ""
2675 if err := SaveConfig(initialCfg); err != nil {
2676 fmt.Fprintln(os.Stderr, "theme reset:", err)
2677 }
2678 }
2679 }
2680 }
2681
2682 // Scope the dashboard to the active host session. The runtime owns
2683 // shutdown separately and closes the resident runtime exactly once.
2684 if sess != nil {
2685 runtime.SetActiveSession(sess.ID)
2686 }
2687
2688 var startupSkills []*skills.Skill
2689 if r.SkillTool != nil {
2690 startupSkills = r.SkillTool.Skills()
2691 }
2692 homeDir, _ := os.UserHomeDir()
2693 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2694 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2695 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2696 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2697 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2698 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2699 autoSubagentsInterruptToolAllowedForSession := autoSubagentsInterruptToolAllowed(args)
2700 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2701 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2702 setWebSearchAvailable := func(available bool) {
2703 webSearchGuard.setAvailable(available)
2704 policy := subagents.WebSearchDeny
2705 if available {
2706 policy = subagents.WebSearchAllow
2707 }
2708 runtime.SetWebSearchPolicy(policy)
2709 }
2710
2711 iv = modes.NewInteractive(modes.InteractiveConfig{
2712 Terminal: term,
2713 Theme: theme,
2714 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2715 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2716 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2717 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2718 WebSearchEnabled: initialCfg.WebSearchEnabled,
2719 WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2720 WebSearchInvocationOverride: webSearchInvocationOverride,
2721 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2722 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2723 AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2724 AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2725 AutoSubagentsInterruptToolAllowed: &autoSubagentsInterruptToolAllowedForSession,
2726 FastMode: &fastMode,
2727 LSPEnabled: initialCfg.LSPEnabled,
2728 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2729 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2730 GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
2731 JailByDefault: initialCfg.JailByDefault,
2732 QuickModelShortcuts: quickModelShortcuts,
2733 ActiveModelProfile: initialCfg.ActiveModelProfile,
2734 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2735 RespectGitignore: initialCfg.RespectGitignore,
2736 CompactMode: initialCfg.CompactMode,
2737 TUIInputStyle: initialCfg.TUIInputStyle,
2738 TUIStatusPosition: initialCfg.TUIStatusPosition,
2739 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2740 ThemeName: initialCfg.Theme,
2741 EffectiveThemeName: themePreference.Effective,
2742 ThemeForced: themePreference.Forced,
2743 TerminalProfile: theme.Terminal,
2744 FlatTools: initialCfg.FlatToolRender(),
2745 CompactUser: initialCfg.CompactUserInput(),
2746 ExtensionThemes: extMgr.ThemeOptions,
2747 AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)),
2748 OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2749 SubagentsSystemAddendum: subagentsAddendum,
2750 SettingsStore: configSettingsStore{},
2751 Model: r.Model,
2752 Provider: r.Provider,
2753 AuthMethod: r.AuthMethod,
2754 BaseURL: r.BaseURL,
2755 Reasoning: r.Reasoning,
2756 OnReasoningChanged: runtime.SetReasoning,
2757 OnFastModeChanged: runtime.SetFastMode,
2758 SystemPrompt: r.SystemPrompt,
2759 WritingGuidance: WritingGuidance(),
2760 Tools: r.ToolRegistry,
2761 MaxSteps: r.MaxSteps,
2762 CWD: r.CWD,
2763 StartupAgentName: args.AgentName,
2764 StartupContextPaths: instructionContextPaths(r.ContextFiles),
2765 StartupExtensionNames: startupExtensionNames(extMgr.All()),
2766 StartupExtensionErrors: startupExtensionErrors,
2767 StartupSkillNames: startupSkillNames(startupSkills),
2768 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2769 ZutHome: ZutHome(),
2770 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2771 SessionsDisabled: args.NoSess,
2772 Version: version,
2773 UpdateInfoChan: updateCh,
2774 Sandbox: sharedSandbox,
2775 Agent: ag,
2776 InitialCompactHandoff: currentCompactHandoff(),
2777 InitialSessionTitle: func() string {
2778 if sess == nil || sessionTitlePending {
2779 return ""
2780 }
2781 return sess.Title
2782 }(),
2783 InitialSessionTitlePending: sessionTitlePending,
2784 InitialInput: args.Prompt,
2785 StartupPre: args.StartupPre,
2786 OnStartupPreDone: func() {
2787 // entry.pre often installs skills/extensions; rediscover them
2788 // before the user starts a model turn.
2789 current := liveInteractiveAgent(iv, ag)
2790 if extMgr != nil {
2791 _ = extMgr.Reload(context.Background(), 2*time.Second)
2792 }
2793 if current != nil {
2794 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil {
2795 iv.ReportError(err)
2796 }
2797 }
2798 },
2799 RefreshTools: func() error {
2800 current := liveInteractiveAgent(iv, ag)
2801 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv)
2802 if err != nil {
2803 if !errors.Is(err, errInteractiveAgentChanged) {
2804 setWebSearchAvailable(false)
2805 }
2806 return err
2807 }
2808 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2809 return nil
2810 },
2811 SetWebSearchAvailable: setWebSearchAvailable,
2812 AuthManager: mgr,
2813 LlamaCPPConfig: ResolveLlamaCPPConfig,
2814 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2815 RefreshCustomProviderModels: RefreshCustomProviderModels,
2816 CustomDiscoveryConfigured: CustomDiscoveryConfigured,
2817 BuildAgent: buildAgent,
2818 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2819 BuildAgentFor: buildAgentFor,
2820 BuildAgentForRescue: buildAgentForRescue,
2821 LoggedInProviders: modelPickerProviders,
2822 LoadSession: loadSession,
2823 LoadSessionContext: loadSessionContext,
2824 ChangeCWD: changeCWD,
2825 PersistCompactHandoff: persistCompactHandoff,
2826 CurrentCompactHandoff: currentCompactHandoff,
2827 PersistGoal: persistGoal,
2828 PersistGoalRuntime: persistGoalRuntime,
2829 CurrentGoal: currentGoal,
2830 CurrentGoalHistory: currentGoalHistory,
2831 CurrentPlan: currentPlan,
2832 PersistPlan: persistPlan,
2833 PersistedPlan: persistedPlan,
2834 InitialPlanSnapshots: initialPlanSnapshots,
2835 EnsureMission: ensureMission,
2836 CurrentSessionPath: func() string {
2837 persistMu.Lock()
2838 defer persistMu.Unlock()
2839 if sess == nil {
2840 return ""
2841 }
2842 return sess.Path
2843 },
2844 FlushSession: func() {
2845 sessionTransitionMu.RLock()
2846 defer sessionTransitionMu.RUnlock()
2847 // Append any not-yet-persisted agent messages to the
2848 // current session file, then advance the baseline so
2849 // the final WriteNewTranscript at exit doesn't write
2850 // duplicates. Per-message persistence keeps the on-
2851 // disk file current already, so this is mostly a
2852 // defensive flush — still needed for /session export
2853 // to guarantee the exported bytes include the very
2854 // last in-flight turn.
2855 currentAg := iv.Agent()
2856 if currentAg == nil {
2857 return
2858 }
2859 persistMu.Lock()
2860 defer persistMu.Unlock()
2861 if sess == nil {
2862 return
2863 }
2864 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2865 sessBaselineMsgs = next
2866 if flushErr != nil {
2867 iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2868 return
2869 }
2870 },
2871 SessionTransition: newSessionTransition(&sessionTransitionMu),
2872 Extensions: extMgr,
2873 ResidentManager: runtime.ResidentManager(),
2874 SpawnResident: func(ctx context.Context, request tools.ResidentSpawnRequest) (string, error) {
2875 manager := runtime.ResidentManager()
2876 if manager == nil {
2877 return "", errors.New("resident runtime is unavailable")
2878 }
2879 catalogue := r.ToolRegistry
2880 if iv != nil && iv.Agent() != nil {
2881 catalogue = iv.Agent().ToolsSnapshot()
2882 }
2883 spec, err := runtime.buildResidentChildSpec(ctx, request, catalogue)
2884 if err != nil {
2885 return "", err
2886 }
2887 if _, err := manager.Spawn(ctx, spec, request.Task); err != nil {
2888 return "", err
2889 }
2890 return spec.ID, nil
2891 },
2892 BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
2893 catalogue := r.ToolRegistry
2894 if iv != nil && iv.Agent() != nil {
2895 catalogue = iv.Agent().ToolsSnapshot()
2896 }
2897 return runtime.buildResidentChildSpec(ctx, request, catalogue)
2898 },
2899 ResolveSubagent: resolveSubagent,
2900 ChangelogChan: changelogCh,
2901 OnChangelogDismiss: func() {
2902 // For development builds, store the actual release version
2903 // so the same changelog doesn't show again next launch.
2904 // For real builds, store the binary version.
2905 v := version
2906 if devBuild {
2907 if iv != nil && iv.ChangelogVersion() != "" {
2908 v = iv.ChangelogVersion()
2909 }
2910 }
2911 _ = MarkChangelogShown(v)
2912 },
2913 SkillSnapshot: func() []*skills.Skill {
2914 return userSkillSnapshot(args, r.CWD, extMgr.Skills())
2915 },
2916 LoadSkillPins: func(cwd string) (skills.Pins, error) {
2917 if args.NoSkill {
2918 return skills.Pins{}, nil
2919 }
2920 return loadSkillPins(cwd)
2921 },
2922 ToggleSkillPin: toggleSkillPin,
2923 PreloadPinnedSkills: freshSession && !args.NoSkill,
2924 NoYolo: args.NoYolo,
2925 ConfirmGate: confirmGate,
2926 PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2927 if iv != nil {
2928 iv.Notify("session", "error", message)
2929 }
2930 }),
2931 CurrentSessionTitle: func() string {
2932 sessionTransitionMu.RLock()
2933 defer sessionTransitionMu.RUnlock()
2934 persistMu.Lock()
2935 defer persistMu.Unlock()
2936 if sess == nil || sessionTitlePending {
2937 return ""
2938 }
2939 return sess.Title
2940 },
2941 CurrentSessionTitlePending: func() bool {
2942 sessionTransitionMu.RLock()
2943 defer sessionTransitionMu.RUnlock()
2944 persistMu.Lock()
2945 defer persistMu.Unlock()
2946 return sessionTitlePending
2947 },
2948 PersistTitle: func(title string) error {
2949 persistMu.Lock()
2950 defer persistMu.Unlock()
2951 if sess == nil {
2952 return nil
2953 }
2954 if err := sess.UpdateTitle(title); err != nil {
2955 return err
2956 }
2957 sessionTitlePending = false
2958 return nil
2959 },
2960 OnSessionTitleChanged: func(title string) {
2961 sessionTransitionMu.RLock()
2962 defer sessionTransitionMu.RUnlock()
2963 persistMu.Lock()
2964 defer persistMu.Unlock()
2965 if sess != nil {
2966 sess.Title = title
2967 sessionTitlePending = false
2968 }
2969 },
2970 })
2971 extHooks.attachInteractive(iv)
2972 schedulerCtx, cancelScheduler := context.WithCancel(ctx)
2973 defer cancelScheduler()
2974 go func() {
2975 if err := scheduleEngine.Run(schedulerCtx, func(taskCtx context.Context, task scheduler.Task) error {
2976 unlockSession := lockScheduledSession(task.SessionID)
2977 defer unlockSession()
2978 // Keep a background session run mutually exclusive with /session
2979 // transitions. The active target uses Interactive's distinct-turn
2980 // ingress instead, so it never injects into an in-flight model loop.
2981 sessionTransitionMu.RLock()
2982 defer sessionTransitionMu.RUnlock()
2983 persistMu.Lock()
2984 activeTarget := sess != nil && sess.ID == task.SessionID
2985 persistMu.Unlock()
2986 if activeTarget {
2987 return iv.SubmitFollowUp(taskCtx, task.Message)
2988 }
2989 // A resumed background agent must schedule any follow-up work against
2990 // the transcript it resumed, not whichever session is visible in the
2991 // interactive TUI while it runs.
2992 backgroundRegistry := func(registry core.Registry) core.Registry {
2993 if schedulerAllowed {
2994 registry[tools.ScheduleToolName] = &tools.ScheduleTool{
2995 Engine: scheduleEngine,
2996 SessionID: func() string { return task.SessionID },
2997 Location: func() *time.Location { return time.Local },
2998 }
2999 }
3000 return registry
3001 }
3002 err := runScheduledSession(taskCtx, task, args, r, extMgr, backgroundRegistry)
3003 if err != nil {
3004 iv.Notify("scheduler", "error", fmt.Sprintf("scheduled task %s failed: %v", task.ID, err))
3005 } else {
3006 iv.Notify("scheduler", "success", fmt.Sprintf("scheduled task %s completed in session %s", task.ID, task.SessionID))
3007 }
3008 return err
3009 }); err != nil && schedulerCtx.Err() == nil {
3010 iv.ReportError(fmt.Errorf("scheduler stopped: %w", err))
3011 }
3012 }()
3013 go func() {
3014 <-reloadDone
3015 for _, reloadErr := range reloadErrs {
3016 if reloadErr != nil {
3017 iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
3018 }
3019 }
3020 }()
3021
3022 // Bind the interactive TUI as the Confirmer. We deferred this
3023 // until now because the gate is constructed before the TUI
3024 // (the BeforeToolExecute closure captures it). SetConfirmer
3025 // is mutex-guarded on the gate so this is safe.
3026 if confirmGate != nil {
3027 confirmGate.SetConfirmer(&confirmationEventConfirmer{
3028 inner: iv,
3029 emit: extMgr.EmitEvent,
3030 })
3031 }
3032
3033 // Signal-driven flush: a SIGTERM / SIGHUP to the zut process
3034 // (closed terminal window, system shutdown, kill) used to lose
3035 // the entire in-memory transcript because the deferred post-Run
3036 // flush below never ran. Per-message persistence above covers
3037 // most of it; this handler writes any in-flight remainder and
3038 // then exits the process so we don't double-paint over a
3039 // broken terminal that the TUI's restore deferreds can no
3040 // longer fix from a signal context.
3041 //
3042 // SIGINT is intentionally NOT handled here — the TUI consumes
3043 // Ctrl+C as a regular key event for cancel/clear semantics, and
3044 // installing a SIGINT notifier here would swallow it.
3045 sigCh := make(chan os.Signal, 1)
3046 signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
3047 defer signal.Stop(sigCh)
3048 go func() {
3049 _, ok := <-sigCh
3050 if !ok {
3051 return
3052 }
3053 finalAg := iv.Agent()
3054 if finalAg != nil {
3055 persistMu.Lock()
3056 if sess != nil {
3057 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
3058 sessBaselineMsgs = next
3059 if flushErr != nil {
3060 fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
3061 }
3062 if closeErr := sess.Close(); closeErr != nil {
3063 fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
3064 }
3065 sess = nil
3066 }
3067 persistMu.Unlock()
3068 }
3069 closeAgentLSP(finalAg)
3070 closeResolvedLSP(r)
3071 // Stop supervised workers before exiting. os.Exit skips deferred
3072 // cleanup, so wait for reload and close the shared runtime explicitly.
3073 <-reloadDone
3074 _ = closeSubagentRuntimeFresh(runtime)
3075 // Exit cleanly. Re-raising the signal would skip os.Exit's
3076 // at-exit hooks; explicit exit is fine because we've already
3077 // flushed the only at-risk state (the session file) and stopped
3078 // supervised workers.
3079 os.Exit(0)
3080 }()
3081
3082 runErr = iv.Run(ctx)
3083
3084 // Flush final transcript to session (only if we had / ended up with an agent).
3085 if finalAg := iv.Agent(); finalAg != nil {
3086 persistMu.Lock()
3087 if sess != nil {
3088 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
3089 sessBaselineMsgs = next
3090 if flushErr != nil {
3091 runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
3092 }
3093 }
3094 persistMu.Unlock()
3095 }
3096 return runErr
3097}
3098
3099func resumableSessionID(root string, sess *core.Session, hasMessages bool) string {
3100 if !hasMessages || sess == nil || sess.ID == "" || sess.Path == "" {
3101 return ""
3102 }
3103 managedRoot, err := filepath.Abs(filepath.Join(root, "sessions"))
3104 if err != nil {
3105 return ""
3106 }
3107 path, err := filepath.Abs(sess.Path)
3108 if err != nil {
3109 return ""
3110 }
3111 rel, err := filepath.Rel(managedRoot, path)
3112 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
3113 return ""
3114 }
3115 if _, err := os.Stat(path); err != nil {
3116 return ""
3117 }
3118 return sess.ID
3119}
3120
3121func resumeSessionHint(id string) string {
3122 return fmt.Sprintf("Session ID: %s\nResume with: zut --resume %s\n\n", id, id)
3123}
3124
3125func agentSessionsRoot(root string, args Args) string {
3126 if args.AgentName == "" {
3127 return root
3128 }
3129 return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
3130}
3131
3132// openOrCreateSession returns a session for the run. sess may be nil
3133// with a nil error if session persistence is disabled.
3134func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
3135 sess, _, err := openOrCreateSessionState(ctx, args, r, ag, version)
3136 return sess, err
3137}
3138
3139func pickSession(root, cwd string) (string, error) {
3140 files := core.ListSessions(root, cwd)
3141 if len(files) == 0 {
3142 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
3143 return "", nil
3144 }
3145 for i, f := range files {
3146 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
3147 }
3148 fmt.Fprint(os.Stderr, "pick #: ")
3149 rd := bufio.NewReader(os.Stdin)
3150 line, _ := rd.ReadString('\n')
3151 line = strings.TrimSpace(line)
3152 var n int
3153 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
3154 return "", fmt.Errorf("invalid selection")
3155 }
3156 return files[n-1], nil
3157}
3158
3159// WriteNewTranscript appends only messages after index `from` from the
3160// agent's transcript to the session. Used by callers that don't hold
3161// the persistMu (non-interactive print/json modes which run a single
3162// turn under their own goroutine).
3163func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
3164 _, err := writeNewTranscriptLocked(ag, sess, from)
3165 return err
3166}
3167
3168func wireRetryLifecyclePersistence(ag *core.Agent, sess *core.Session) {
3169 if ag == nil {
3170 return
3171 }
3172 if sess == nil {
3173 ag.OnRetryLifecycle = nil
3174 return
3175 }
3176 ag.OnRetryLifecycle = sess.RecordRetryLifecycle
3177}
3178
3179// writeNewTranscriptLocked is the same as WriteNewTranscript. The
3180// suffix marks that interactive callers must hold persistMu when
3181// invoking it so concurrent appends from the agent loop don't race
3182// with this catch-up flush.
3183func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
3184 next = from
3185 if sess == nil || ag == nil {
3186 return next, nil
3187 }
3188 msgs, total := ag.MessagesFrom(from)
3189 for offset, msg := range msgs {
3190 index := from + offset
3191 if err := sess.AppendMessage(msg); err != nil {
3192 return next, fmt.Errorf("append transcript message %d: %w", index, err)
3193 }
3194 next = index + 1
3195 }
3196 if len(msgs) == 0 {
3197 next = total
3198 }
3199 cum := ag.Cost()
3200 if err := sess.AppendUsage(cum, cum); err != nil {
3201 return next, fmt.Errorf("append transcript usage: %w", err)
3202 }
3203 if err := sess.Flush(); err != nil {
3204 return next, fmt.Errorf("flush transcript: %w", err)
3205 }
3206 return next, nil
3207}
3208
3209func argsForStdin(rawArgs []string, mode os.FileMode) []string {
3210 if mode&os.ModeCharDevice != 0 {
3211 return rawArgs
3212 }
3213 return append([]string{"--print"}, rawArgs...)
3214}
3215
3216func combinePromptInput(stdin, positional string) string {
3217 parts := make([]string, 0, 2)
3218 if stdin = strings.TrimSpace(stdin); stdin != "" {
3219 parts = append(parts, stdin)
3220 }
3221 if positional = strings.TrimSpace(positional); positional != "" {
3222 parts = append(parts, positional)
3223 }
3224 return strings.Join(parts, "\n")
3225}
3226
3227func readAllStdin() (string, error) {
3228 fi, err := os.Stdin.Stat()
3229 if err != nil {
3230 return "", err
3231 }
3232 if (fi.Mode() & os.ModeCharDevice) != 0 {
3233 return "", nil
3234 }
3235 return readAllStdinFrom(os.Stdin)
3236}
3237
3238func readAllStdinFrom(r io.Reader) (string, error) {
3239 b, err := io.ReadAll(r)
3240 return string(b), err
3241}
3242
3243func printModels() {
3244 models := provider.Active()
3245
3246 // Compute column widths from actual data so wide providers (e.g.
3247 // xiaomi-token-plan-sgp) and long bedrock model ids don't force the
3248 // `name` column off-screen. Floors mirror the historical layout so
3249 // short catalogs look the same as before.
3250 provW, idW, srcW := len("provider"), len("model id"), len("source")
3251 for _, m := range models {
3252 if w := len(m.Provider); w > provW {
3253 provW = w
3254 }
3255 if w := len(m.ID); w > idW {
3256 idW = w
3257 }
3258 source := m.Source
3259 if source == "" {
3260 source = "catalog"
3261 }
3262 if m.Speculative {
3263 source = "speculative"
3264 }
3265 if w := len(source); w > srcW {
3266 srcW = w
3267 }
3268 }
3269
3270 header := fmt.Sprintf("%-*s %-*s %8s %8s %s %-*s %s",
3271 provW, "provider",
3272 idW, "model id",
3273 "context", "max-out", "reasoning",
3274 srcW, "source",
3275 "name")
3276 fmt.Println(header)
3277
3278 for _, m := range models {
3279 reason := " "
3280 if m.Reasoning {
3281 reason = "✓"
3282 }
3283 source := m.Source
3284 if source == "" {
3285 source = "catalog"
3286 }
3287 if m.Speculative {
3288 source = "speculative"
3289 }
3290 fmt.Printf("%-*s %-*s %8d %8d %s %-*s %s\n",
3291 provW, m.Provider,
3292 idW, m.ID,
3293 m.ContextWindow, m.MaxOutput,
3294 reason,
3295 srcW, source,
3296 m.DisplayName)
3297 }
3298}
3299
3300// confirmationKeyFor returns the session-scoped confirmation grant key for a
3301// tool call. A tool that implements core.ConfirmationKeyer may expose a finer
3302// key than its name (the subagent facade keys one grant per action); every
3303// other tool, and any unknown tool, keeps its own name. It mirrors the
3304// confirmation gate's lookup so a remembered grant matches the call that will
3305// actually run.
3306func confirmationKeyFor(reg core.Registry, name string, args json.RawMessage) string {
3307 tool, err := reg.Get(name)
3308 if err != nil {
3309 return name
3310 }
3311 return core.ConfirmationKey(tool, name, args)
3312}