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