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