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