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