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