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