interactive_resident_subagents.go RawHistoryFolder
1package modes
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/bnema/zut/packages/agent/internal/orchestration"
13 "github.com/bnema/zut/packages/agent/modes/telegram"
14 "github.com/bnema/zut/packages/agent/subagents"
15 "github.com/bnema/zut/packages/agent/tools"
16 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"
18 "github.com/bnema/zut/packages/tui"
19)
20
21func (i *Interactive) TrackResidentSubagent(childID, turnID string) {
22 if i == nil {
23 return
24 }
25 if !i.ensureCompletionTracker().TrackResident(childID, turnID) {
26 return
27 }
28 i.registerCoordinatorWorker(childID)
29 i.invalidate()
30 i.requestCompletionDelivery()
31}
32func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
33 if i == nil {
34 return
35 }
36 status, errText := "completed", ""
37 if completion.Err != nil {
38 status, errText = "failed", completion.Err.Error()
39 if errors.Is(completion.Err, context.Canceled) {
40 status = "interrupted"
41 }
42 }
43 if !i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, TurnID: completion.TurnID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) {
44 return
45 }
46 i.reloadOpenResidentChildSession(completion.ChildID)
47 i.invalidate()
48 i.requestCompletionDelivery()
49}
50func (i *Interactive) reloadOpenResidentChildSession(childID string) {
51 if i == nil {
52 return
53 }
54 i.mu.Lock()
55 session := i.residentChildSession
56 i.mu.Unlock()
57 if session == nil || session.childID != childID || !session.RequestRecentReload() {
58 return
59 }
60 i.reloadResidentChildSession(session)
61}
62func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
63 if i == nil || session == nil {
64 return
65 }
66 go func() {
67 err := session.ReloadAll(200, i.invalidate)
68 if session.FinishLoad(err) {
69 if session.RequestRecentReload() {
70 i.reloadResidentChildSession(session)
71 }
72 }
73 i.invalidate()
74 }()
75}
76func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
77 // Tests and lightweight embedders may construct Interactive with a struct
78 // literal instead of NewInteractive, so retain this lazy fallback.
79 i.completionDeliveryMu.Lock()
80 defer i.completionDeliveryMu.Unlock()
81 if i.completionTracker == nil {
82 i.completionTracker = subagents.NewCompletionTracker()
83 }
84 return i.completionTracker
85}
86func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
87 if i.turnCoordinator == nil {
88 i.turnCoordinator = orchestration.New()
89 }
90 return i.turnCoordinator
91}
92func (i *Interactive) coordinatorAcceptsUserInput() bool {
93 if i == nil {
94 return false
95 }
96 i.completionDeliveryMu.Lock()
97 accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
98 i.completionDeliveryMu.Unlock()
99 return accepts
100}
101func (i *Interactive) coordinatorHasPendingWorkers() bool {
102 if i == nil {
103 return false
104 }
105 i.completionDeliveryMu.Lock()
106 pending := i.ensureCoordinatorLocked().HasPendingWorkers()
107 i.completionDeliveryMu.Unlock()
108 return pending
109}
110func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
111 if i == nil {
112 return nil
113 }
114 i.completionDeliveryMu.Lock()
115 result := i.ensureCoordinatorLocked().Apply(event)
116 i.completionDeliveryMu.Unlock()
117 return result.Actions
118}
119func (i *Interactive) registerCoordinatorWorker(workerID string) {
120 if i == nil || workerID == "" {
121 return
122 }
123 i.completionDeliveryMu.Lock()
124 coordinator := i.ensureCoordinatorLocked()
125 implicitWave := i.completionDeliveryHolds == 0
126 if implicitWave {
127 coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
128 }
129 i.coordinatorWorkerSeq++
130 registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
131 if i.coordinatorWorkerIDs == nil {
132 i.coordinatorWorkerIDs = make(map[string][]string)
133 }
134 i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
135 coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
136 var actions []orchestration.Action
137 if implicitWave {
138 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
139 }
140 i.completionDeliveryMu.Unlock()
141 i.executeCoordinatorActions(actions)
142}
143func (i *Interactive) cancelCoordinator() {
144 if i == nil {
145 return
146 }
147 i.completionDeliveryMu.Lock()
148 coordinator := i.ensureCoordinatorLocked()
149 actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
150 // A later user turn starts a fresh wave. Drop resident registrations as
151 // well as coordinator identities so late outcomes cannot match a new child.
152 if i.completionTracker != nil {
153 i.completionTracker.Reset()
154 }
155 i.turnCoordinator = orchestration.New()
156 i.coordinatorWorkerIDs = nil
157 i.completionDeliveryMu.Unlock()
158 i.executeCoordinatorActions(actions)
159}
160func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
161 if i == nil || agentID == "" {
162 return ""
163 }
164 i.completionDeliveryMu.Lock()
165 defer i.completionDeliveryMu.Unlock()
166 ids := i.coordinatorWorkerIDs[agentID]
167 if len(ids) == 0 {
168 return ""
169 }
170 workerID := ids[0]
171 if len(ids) == 1 {
172 delete(i.coordinatorWorkerIDs, agentID)
173 } else {
174 i.coordinatorWorkerIDs[agentID] = ids[1:]
175 }
176 return workerID
177}
178func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
179 for _, action := range actions {
180 if action.Kind != orchestration.ActionRunManager {
181 continue
182 }
183 prompt := action.Text
184 if len(action.Completions) != 0 {
185 instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
186 update := subagents.FormatCompletionUpdate(action.Completions, instruction)
187 if prompt != "" {
188 prompt = update + "\n\nQueued user request:\n" + prompt
189 } else {
190 prompt = update
191 }
192 }
193 if prompt != "" {
194 i.submitOrQueue(prompt, nil, false)
195 } else if action.Reason == orchestration.WakeGoal {
196 parent := i.runCtx
197 if parent == nil {
198 parent = context.Background()
199 }
200 i.requestGoalContinuationIfIdle(parent)
201 }
202 }
203}
204func (i *Interactive) requestCompletionDelivery() {
205 if i == nil {
206 return
207 }
208 i.ensureCompletionTracker()
209 i.completionDeliveryMu.Lock()
210 i.completionDeliveryRequest = true
211 if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
212 i.completionDeliveryMu.Unlock()
213 return
214 }
215 i.completionDeliveryRunning = true
216 i.completionDeliveryMu.Unlock()
217
218 go i.deliverCompletionUpdates()
219}
220func (i *Interactive) beginCompletionDeliveryHold() func() {
221 if i == nil {
222 return func() {}
223 }
224 i.completionDeliveryMu.Lock()
225 i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
226 i.completionDeliveryHolds++
227 i.completionDeliveryMu.Unlock()
228 return i.releaseCompletionDeliveryHold
229}
230func (i *Interactive) releaseCompletionDeliveryHold() {
231 if i == nil {
232 return
233 }
234 start := false
235 var actions []orchestration.Action
236 i.completionDeliveryMu.Lock()
237 if i.completionDeliveryHolds > 0 {
238 i.completionDeliveryHolds--
239 }
240 if i.completionDeliveryHolds == 0 {
241 // The coordinator must observe the goal before sealing the wave, so a
242 // pending worker wins the wake decision instead of a direct continuation.
243 _, goalActive := i.goalContinuationMessage()
244 coordinator := i.ensureCoordinatorLocked()
245 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
246 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
247 }
248 if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
249 i.completionDeliveryRunning = true
250 start = true
251 }
252 i.completionDeliveryMu.Unlock()
253 i.executeCoordinatorActions(actions)
254 if start {
255 go i.deliverCompletionUpdates()
256 }
257}
258func (i *Interactive) completionWaitContext() context.Context {
259 i.mu.Lock()
260 ctx := i.runCtx
261 i.mu.Unlock()
262 if ctx == nil {
263 return context.Background()
264 }
265 return ctx
266}
267func (i *Interactive) deliverCompletionUpdates() {
268 for {
269 i.completionDeliveryMu.Lock()
270 i.completionDeliveryRequest = false
271 tracker := i.completionTracker
272 i.completionDeliveryMu.Unlock()
273
274 batch, err := tracker.WaitIdle(i.completionWaitContext())
275 if err == nil && len(batch) != 0 {
276 var actions []orchestration.Action
277 for _, completion := range batch {
278 workerID := i.takeCoordinatorWorkerID(completion.AgentID)
279 if workerID == "" {
280 continue
281 }
282 actions = append(actions, i.applyCoordinator(orchestration.Event{
283 Kind: orchestration.EventWorkerFinished,
284 WorkerID: workerID,
285 Completion: completion,
286 })...)
287 }
288 i.executeCoordinatorActions(actions)
289 }
290
291 i.completionDeliveryMu.Lock()
292 if i.completionDeliveryRequest {
293 i.completionDeliveryMu.Unlock()
294 continue
295 }
296 i.completionDeliveryRunning = false
297 i.completionDeliveryMu.Unlock()
298 return
299 }
300}
301func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
302 addenda := make([]string, 0, 2)
303 if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
304 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
305 addenda = append(addenda, addendum)
306 }
307 }
308 if !orchestrating {
309 if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
310 if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
311 addenda = append(addenda, addendum)
312 }
313 }
314 return addenda
315 }
316
317 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
318 addenda = append(addenda, addendum)
319 }
320 return addenda
321}
322func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
323 idx := strings.LastIndex(system, addendum)
324 if idx < 0 {
325 return system, false
326 }
327 return system[:idx] + system[idx+len(addendum):], true
328}
329func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
330 // i.mu is sufficient here: this path updates the existing agent's system
331 // prompt and managed addenda in place; it does not replace the agent or
332 // its tool registry, so agentMu is not required.
333 i.mu.Lock()
334 defer i.mu.Unlock()
335 if i.agent == nil {
336 return
337 }
338
339 sys, _ := i.agent.PromptConfig()
340 changed := false
341 if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
342 var removed bool
343 sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
344 changed = changed || removed
345 }
346 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
347 var removed bool
348 sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
349 changed = changed || removed
350 }
351 i.managedAutoSubagentsAddenda = nil
352
353 addenda := autoSubagentsAddenda(i.cfg, orchestrating)
354 if changed {
355 sys = strings.TrimRight(sys, "\n")
356 }
357 for _, addendum := range addenda {
358 if sys != "" {
359 sys += "\n\n"
360 }
361 sys += addendum
362 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
363 changed = true
364 }
365 if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
366 if sys != "" {
367 sys += "\n\n"
368 }
369 sys += guidance
370 changed = true
371 }
372 if !changed {
373 return
374 }
375 i.agent.SetSystemPrompt(sys)
376}
377func (i *Interactive) autoSubagentsAvailable() bool {
378 return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
379}
380func (i *Interactive) autoSubagentsUnavailableHint() string {
381 var hints []string
382 if i.cfg.ResidentManager == nil {
383 hints = append(hints, "resident subagent manager is unavailable in this mode")
384 }
385 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
386 hints = append(hints, "launch-time tool policy excludes subagent manager tools")
387 }
388 return strings.Join(hints, "; ")
389}
390func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
391 return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
392}
393func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
394 if cfg.AutoSubagentsStatusToolAllowed != nil {
395 return *cfg.AutoSubagentsStatusToolAllowed
396 }
397 // Preserve the old single-boolean embedding contract: when callers have
398 // not supplied the new field, status follows the existing delegation gate.
399 return autoSubagentsToolAllowedConfig(cfg)
400}
401func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
402 if cfg.AutoSubagentsStopToolAllowed != nil {
403 return *cfg.AutoSubagentsStopToolAllowed
404 }
405 return autoSubagentsToolAllowedConfig(cfg)
406}
407func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
408 if cfg.AutoSubagentsResumeToolAllowed != nil {
409 return *cfg.AutoSubagentsResumeToolAllowed
410 }
411 return autoSubagentsToolAllowedConfig(cfg)
412}
413func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
414 return autoSubagentsToolAllowedConfig(cfg) ||
415 autoSubagentsStatusToolAllowedConfig(cfg) ||
416 autoSubagentsStopToolAllowedConfig(cfg) ||
417 autoSubagentsResumeToolAllowedConfig(cfg)
418}
419func (i *Interactive) autoSubagentsToolAllowed() bool {
420 return autoSubagentsToolAllowedConfig(i.cfg)
421}
422func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
423 return autoSubagentsStatusToolAllowedConfig(i.cfg)
424}
425func (i *Interactive) autoSubagentsStopToolAllowed() bool {
426 return autoSubagentsStopToolAllowedConfig(i.cfg)
427}
428func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
429 return autoSubagentsResumeToolAllowedConfig(i.cfg)
430}
431func (i *Interactive) autoSubagentsEnabledLocked() bool {
432 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
433}
434func (i *Interactive) applyAutoSubagentsTool() {
435 i.agentMu.Lock()
436 defer i.agentMu.Unlock()
437 i.mu.Lock()
438 defer i.mu.Unlock()
439 if i.agent == nil {
440 return
441 }
442 current := i.agent.ToolsSnapshot()
443 next := core.Registry{}
444 for name, t := range current {
445 if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
446 continue
447 }
448 next[name] = t
449 }
450 if i.autoSubagentsAvailable() {
451 if i.autoSubagentsToolAllowed() {
452 canonical := &tools.SubagentSpawnTool{
453 ResidentManager: i.cfg.ResidentManager,
454 BuildResidentSpec: i.cfg.BuildResidentSpec,
455 Enabled: func() bool { return true },
456 DefaultModel: func() string { return i.cfg.Model },
457 DefaultProvider: func() string { return i.cfg.Provider },
458 DefaultReasoning: func() string { return i.cfg.Reasoning },
459 ResolveSubagent: i.cfg.ResolveSubagent,
460 }
461 next[canonical.Name()] = canonical
462 }
463 if i.autoSubagentsStatusToolAllowed() {
464 statusTool := &tools.SubagentStatusTool{
465 ResidentManager: i.cfg.ResidentManager,
466 Enabled: func() bool { return true },
467 }
468 next[statusTool.Name()] = statusTool
469 }
470 if i.autoSubagentsStopToolAllowed() {
471 stopTool := &tools.SubagentStopTool{
472 ResidentManager: i.cfg.ResidentManager,
473 Enabled: func() bool { return true },
474 }
475 next[stopTool.Name()] = stopTool
476 }
477 if i.autoSubagentsResumeToolAllowed() {
478 resumeTool := &tools.SubagentResumeTool{
479 ResidentManager: i.cfg.ResidentManager,
480 Enabled: func() bool { return true },
481 }
482 next[resumeTool.Name()] = resumeTool
483 }
484 }
485 i.agent.SetTools(next)
486}
487func (i *Interactive) applyTelegramTools(active bool) {
488 if active {
489 // The child ceiling must be in place even when no live main agent exists,
490 // and before Bridge.Start can accept an external prompt.
491 i.setWebSearchAvailable(false)
492 }
493 i.agentMu.Lock()
494 defer i.agentMu.Unlock()
495 i.mu.Lock()
496 defer i.mu.Unlock()
497 if i.agent == nil {
498 return
499 }
500 current := i.agent.ToolsSnapshot()
501 next := core.Registry{}
502 for name, t := range current {
503 if name == "telegram_send_image" || name == "telegram_send_file" {
504 continue
505 }
506 if active && name == "web_search" {
507 // External Telegram prompts have no per-request confirmation
508 // surface, so V1 does not expose native web search while paired.
509 continue
510 }
511 next[name] = t
512 }
513 if active {
514 sender := telegramSenderAdapter{bridge: i.telegramBridge}
515 next["telegram_send_image"] = &tools.TelegramSendImageTool{
516 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
517 }
518 next["telegram_send_file"] = &tools.TelegramSendFileTool{
519 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
520 }
521 }
522 i.agent.SetTools(next)
523 _, webSearchAvailable := next["web_search"]
524 i.setWebSearchAvailable(webSearchAvailable && !active)
525}
526func (i *Interactive) telegramStatus() {
527 var msg string
528 if i.telegramBridge != nil && i.telegramBridge.Active() {
529 s := i.telegramBridge.State()
530 msg = "telegram: connected (tui bridge)"
531 if s.Username != "" {
532 msg += " as @" + s.Username
533 }
534 if s.PairedID != 0 {
535 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
536 } else {
537 msg += " - awaiting pairing"
538 }
539 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
540 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
541 } else {
542 cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
543 if cfg.BotToken == "" {
544 msg = "telegram: not configured. run `zut telegram-bot setup` first."
545 } else {
546 msg = "telegram: disconnected"
547 if cfg.BotUsername != "" {
548 msg += " (@" + cfg.BotUsername + " ready to connect)"
549 }
550 }
551 }
552 i.mu.Lock()
553 i.statusOK = msg
554 i.statusErr = ""
555 i.mu.Unlock()
556 i.invalidate()
557}
558func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
559 h.iv.SubmitOrQueue(prompt, images)
560}
561func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
562func (h *telegramHost) Status() string {
563 h.iv.mu.Lock()
564 providerName := h.iv.cfg.Provider
565 model := h.iv.cfg.Model
566 cwd := h.iv.cfg.CWD
567 usage := h.iv.cumUsage
568 subscription := h.iv.cfg.AuthMethod == "oauth"
569 ctxUsed := h.iv.lastCtxInput
570 busy := h.iv.busy
571 queued := len(h.iv.queued)
572 h.iv.mu.Unlock()
573
574 ctxMax := 0
575 if m, err := provider.FindModel(providerName, model); err == nil {
576 ctxMax = m.ContextWindow
577 }
578 return telegram.FormatStatus(telegram.StatusSnapshot{
579 Provider: providerName,
580 Model: model,
581 CWD: cwd,
582 Usage: usage,
583 Subscription: subscription,
584 ContextUsed: ctxUsed,
585 ContextMax: ctxMax,
586 Busy: busy,
587 Queued: queued,
588 })
589}
590func (h *telegramHost) Notify(level, message string) {
591 h.iv.mu.Lock()
592 switch level {
593 case "error", "warn":
594 h.iv.statusErr = message
595 h.iv.statusOK = ""
596 default:
597 h.iv.statusOK = message
598 h.iv.statusErr = ""
599 }
600 h.iv.mu.Unlock()
601 h.iv.invalidate()
602}
603func (i *Interactive) openSessionOpsDialog() {
604 items := []sessionOpsItem{
605 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
606 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
607 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
608 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
609 }
610 i.sessionOpsDialog.Open(items)
611 i.invalidate()
612}
613func (i *Interactive) doSessionOp(action, arg string) {
614 switch action {
615 case "export":
616 i.doSessionExport(arg)
617 case "import":
618 i.doSessionImport(arg)
619 case "fork":
620 i.doSessionFork()
621 case "tree":
622 i.doSessionTree()
623 default:
624 i.mu.Lock()
625 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
626 i.mu.Unlock()
627 i.invalidate()
628 }
629}
630func (i *Interactive) doSessionExport(dst string) {
631 src := i.currentSessionPath()
632 if src == "" {
633 i.mu.Lock()
634 i.statusErr = "export: no session is active (running with --no-session?)"
635 i.mu.Unlock()
636 i.invalidate()
637 return
638 }
639 // Persist any in-memory agent messages to the session file so
640 // the export carries the full conversation. Without this, the
641 // default lazy-flush-at-exit strategy leaves most of a running
642 // session unwritten and the export ends up with only the meta.
643 if i.cfg.FlushSession != nil {
644 i.cfg.FlushSession()
645 }
646 dst = unquotePath(dst)
647 if dst == "" {
648 dst = defaultExportDir()
649 } else {
650 dst = expandTilde(dst)
651 }
652 out, err := core.ExportSession(src, dst)
653 if err != nil {
654 i.mu.Lock()
655 i.statusErr = "export: " + err.Error()
656 i.mu.Unlock()
657 i.invalidate()
658 return
659 }
660 i.mu.Lock()
661 i.statusOK = "exported session to " + friendlyPath(out)
662 i.statusErr = ""
663 i.mu.Unlock()
664 i.invalidate()
665}
666func (i *Interactive) doSessionImport(src string) {
667 src = unquotePath(src)
668 if src == "" {
669 i.mu.Lock()
670 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
671 i.mu.Unlock()
672 i.invalidate()
673 return
674 }
675 src = expandTilde(src)
676 if _, err := os.Stat(src); err != nil {
677 i.mu.Lock()
678 i.statusErr = "import: " + err.Error()
679 i.mu.Unlock()
680 i.invalidate()
681 return
682 }
683 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
684 if err != nil {
685 i.mu.Lock()
686 i.statusErr = "import: " + err.Error()
687 i.mu.Unlock()
688 i.invalidate()
689 return
690 }
691 if i.cfg.LoadSession == nil {
692 i.mu.Lock()
693 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
694 i.statusErr = ""
695 i.mu.Unlock()
696 i.invalidate()
697 return
698 }
699 i.markSessionTitleSwitching()
700 if err := i.cfg.LoadSession(newPath); err != nil {
701 i.restoreFailedSessionTitle()
702 i.mu.Lock()
703 i.statusErr = "import: load failed: " + err.Error()
704 i.mu.Unlock()
705 i.invalidate()
706 return
707 }
708 i.restoreLoadedSessionTitle()
709 state := i.restoreCurrentCompactHandoff()
710 i.mu.Lock()
711 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
712 i.statusErr = ""
713 if i.agent != nil {
714 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
715 i.cumUsage = i.agent.Cost()
716 last := i.agent.LastTurnUsage()
717 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
718 if len(i.view.Messages) > initialResumeTailLimit {
719 i.view.TailLimit = initialResumeTailLimit
720 } else {
721 i.view.TailLimit = 0
722 }
723 i.view.InvalidateRenderCache()
724 }
725 i.mu.Unlock()
726 i.invalidate()
727 if state.reason != compactContinuationNone {
728 i.startRestoredCompactHandoff(i.runCtx)
729 }
730}
731func defaultExportDir() string {
732 home, err := os.UserHomeDir()
733 if err != nil || home == "" {
734 return os.TempDir()
735 }
736 downloads := filepath.Join(home, "Downloads")
737 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
738 return downloads
739 }
740 return home
741}
742func expandTilde(p string) string {
743 if p == "" || p[0] != '~' {
744 return p
745 }
746 home, err := os.UserHomeDir()
747 if err != nil || home == "" {
748 return p
749 }
750 if len(p) == 1 {
751 return home
752 }
753 if p[1] == '/' || p[1] == filepath.Separator {
754 return filepath.Join(home, p[2:])
755 }
756 return p
757}
758func unquotePath(p string) string {
759 p = strings.TrimSpace(p)
760 if len(p) >= 2 {
761 first := p[0]
762 last := p[len(p)-1]
763 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
764 p = p[1 : len(p)-1]
765 }
766 }
767 return p
768}
769func friendlyPath(p string) string {
770 home, err := os.UserHomeDir()
771 if err != nil || home == "" {
772 return p
773 }
774 if strings.HasPrefix(p, home+string(filepath.Separator)) {
775 return "~" + p[len(home):]
776 }
777 return p
778}
779func (i *Interactive) doSessionFork() {
780 path := i.currentSessionPath()
781 if path == "" {
782 i.mu.Lock()
783 i.statusErr = "fork: no session is active (running with --no-session?)"
784 i.mu.Unlock()
785 i.invalidate()
786 return
787 }
788 i.mu.Lock()
789 busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
790 queued := len(i.queued) != 0
791 ag := i.agent
792 i.mu.Unlock()
793 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
794 i.mu.Lock()
795 i.statusErr = "fork: wait until the current turn and queued messages finish"
796 i.mu.Unlock()
797 i.invalidate()
798 return
799 }
800 if i.cfg.FlushSession != nil {
801 i.cfg.FlushSession()
802 }
803 snapshot, err := core.ReadSessionSnapshot(path)
804 if err != nil {
805 i.mu.Lock()
806 i.statusErr = "fork: " + err.Error()
807 i.mu.Unlock()
808 i.invalidate()
809 return
810 }
811 msgs := snapshot.Messages
812 if len(msgs) == 0 {
813 i.mu.Lock()
814 i.statusErr = "fork: transcript is empty; nothing to fork from"
815 i.mu.Unlock()
816 i.invalidate()
817 return
818 }
819 i.pendingFork = true
820 i.jumpDialog.Open(msgs, "")
821 i.invalidate()
822}
823func (i *Interactive) doSessionTree() {
824 i.openSessionTree()
825}
826func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
827 target := sessionTreeTarget{
828 SourcePath: src,
829 EffectiveIndex: msgIdx,
830 SelectionBoundary: msgIdx + 1,
831 Role: role,
832 UserDraft: prompt,
833 Boundary: sessionTreeMessageBoundary,
834 }
835 if role == provider.RoleUser {
836 target.SelectionBoundary = msgIdx
837 }
838 i.applySessionTreeTarget(target, turnNo)
839}
840func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
841 // Selection is a state-changing operation too. Use the selection gate so
842 // a queued/busy turn cannot race the hidden branch or accidentally submit
843 // a provider turn while the session is being swapped. The open gate cannot
844 // be reused here because the tree dialog itself owns the keyboard.
845 if !i.canCommitSessionTreeSelection() {
846 i.setSessionTreeError("tree: session branching is not available in this build")
847 return
848 }
849 src := target.SourcePath
850 if src == "" {
851 src = i.currentSessionPath()
852 }
853 if src == "" {
854 i.setSessionTreeError("tree: no session is active")
855 return
856 }
857
858 // Read and validate the selected row before flushing or creating anything.
859 // This gives tool-call/result pairs an atomic boundary and prevents a
860 // malformed selection from changing the active session. Historical rows
861 // come from a pre-compaction segment; current rows use the effective
862 // snapshot that the running agent resumes from.
863 var msgs []provider.Message
864 var historicalSegment core.SessionHistorySegment
865 if target.Historical {
866 history, err := core.ReadSessionHistory(src)
867 if err != nil {
868 i.setSessionTreeError("tree: read selection: " + err.Error())
869 return
870 }
871 if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
872 i.setSessionTreeError("tree: selected history segment is unavailable")
873 return
874 }
875 historicalSegment = history.Segments[target.HistorySegment]
876 msgs = historicalSegment.Messages
877 } else {
878 sess, current, err := core.OpenSession(src)
879 if err != nil {
880 i.setSessionTreeError("tree: read selection: " + err.Error())
881 return
882 }
883 if err := sess.Close(); err != nil {
884 i.setSessionTreeError("tree: close selection: " + err.Error())
885 return
886 }
887 msgs = current
888 }
889 selection, err := sessionTreeSelection(msgs, target)
890 if err != nil {
891 i.setSessionTreeError("tree: " + err.Error())
892 return
893 }
894 if i.cfg.FlushSession != nil {
895 i.cfg.FlushSession()
896 }
897 var newPath string
898 if target.Historical {
899 newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
900 } else {
901 newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
902 }
903 if err != nil {
904 i.setSessionTreeError("tree: " + err.Error())
905 return
906 }
907 i.markSessionTitleSwitching()
908 if err := i.cfg.LoadSession(newPath); err != nil {
909 i.restoreFailedSessionTitle()
910 i.setSessionTreeError("tree: checkout failed: " + err.Error())
911 return
912 }
913 state := i.restoreCurrentCompactHandoff()
914
915 // LoadSession is intentionally the old func(string) error callback: the
916 // CLI and embedders already own agent/session swapping. Refresh the view
917 // from the swapped agent here instead of requiring a new callback result.
918 i.resetSessionTitleForFreshBranch()
919 i.scrollToBottom()
920 i.mu.Lock()
921 i.lastCtxInput = 0
922 if i.agent != nil {
923 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
924 i.cumUsage = i.agent.Cost()
925 last := i.agent.LastTurnUsage()
926 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
927 i.view.InvalidateRenderCache()
928 }
929 i.toolCalls = map[string]*tui.ToolCallView{}
930 i.toolOrder = nil
931 i.toolGate = map[string]int{}
932 i.extNotes = nil
933 if selection.restoreDraft {
934 i.ed.SetValue(selection.draftText)
935 i.clipboardImages = selection.images
936 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
937 } else {
938 i.ed.Clear()
939 i.clipboardImages = nil
940 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
941 }
942 i.statusErr = ""
943 i.mu.Unlock()
944 i.scrollToBottom()
945 // Selection does not submit, start, or queue the restored user draft; only
946 // a later explicit Enter does that. An inherited compact handoff may resume
947 // its hidden continuation below when this branch has the complete effective
948 // transcript, without submitting the restored draft.
949 i.invalidate()
950 if state.reason != compactContinuationNone {
951 i.startRestoredCompactHandoff(i.runCtx)
952 }
953}
954func (i *Interactive) applyForkSelection(msgIdx int) {
955 i.pendingFork = false
956 src := i.currentSessionPath()
957 if src == "" {
958 i.mu.Lock()
959 i.statusErr = "fork: no session is active"
960 i.mu.Unlock()
961 i.invalidate()
962 return
963 }
964 if i.cfg.FlushSession != nil {
965 i.cfg.FlushSession()
966 }
967 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
968 // the selected user message is included.
969 upTo := msgIdx + 1
970 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
971 if err != nil {
972 i.mu.Lock()
973 i.statusErr = "fork: " + err.Error()
974 i.mu.Unlock()
975 i.invalidate()
976 return
977 }
978 if i.cfg.LoadSession == nil {
979 i.mu.Lock()
980 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
981 i.statusErr = ""
982 i.mu.Unlock()
983 i.invalidate()
984 return
985 }
986 i.markSessionTitleSwitching()
987 if err := i.cfg.LoadSession(newPath); err != nil {
988 i.restoreFailedSessionTitle()
989 i.mu.Lock()
990 i.statusErr = "fork: switch failed: " + err.Error()
991 i.mu.Unlock()
992 i.invalidate()
993 return
994 }
995 state := i.restoreCurrentCompactHandoff()
996 i.resetSessionTitleForFreshBranch()
997 i.mu.Lock()
998 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
999 i.statusErr = ""
1000 i.mu.Unlock()
1001 i.invalidate()
1002 if state.reason != compactContinuationNone {
1003 i.startRestoredCompactHandoff(i.runCtx)
1004 }
1005}
1006func formatInt(n int) string {
1007 return fmt.Sprintf("%d", n)
1008}
1009func assistantText(m provider.Message) string {
1010 var sb strings.Builder
1011 for _, c := range m.Content {
1012 if tb, ok := c.(provider.TextBlock); ok {
1013 if sb.Len() > 0 {
1014 sb.WriteByte('\n')
1015 }
1016 sb.WriteString(tb.Text)
1017 }
1018 }
1019 return sb.String()
1020}
1021func (i *Interactive) resetTranscriptRenderLocked() {
1022 i.view.InvalidateRenderCache()
1023 i.chatCacheValid = false
1024 i.prevChatLen = 0
1025 i.prevChatCols = 0
1026 i.prevChatRows = 0
1027 i.prevScrollOffset = 0
1028 i.requestRendererInvalidate()
1029}
1030func (i *Interactive) resetStreamingStateLocked() {
1031 i.streaming.Reset()
1032 i.streamPending = i.streamPending[:0]
1033 i.streamFlushPending = false
1034 i.streamOn = false
1035 i.openAllToolGatesLocked()
1036}
1037func (i *Interactive) openAllToolGatesLocked() {
1038 for id := range i.toolGate {
1039 i.toolGate[id] = 0
1040 }
1041}
1042func (i *Interactive) gateToolLocked(id string) {
1043 if _, ok := i.toolGate[id]; ok {
1044 return
1045 }
1046 if !i.streamOn {
1047 i.toolGate[id] = 0
1048 return
1049 }
1050 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1051}
1052func (i *Interactive) toolGateOpenLocked(id string) bool {
1053 gate, ok := i.toolGate[id]
1054 if !ok || gate == 0 {
1055 return true
1056 }
1057 return i.streaming.Len() >= gate
1058}
1059func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1060 if i.cfg.OnAssistant != nil {
1061 i.cfg.OnAssistant(m)
1062 }
1063 if i.telegramBridge != nil && i.telegramBridge.Active() {
1064 var sb strings.Builder
1065 for _, c := range m.Content {
1066 if tb, ok := c.(provider.TextBlock); ok {
1067 if sb.Len() > 0 {
1068 sb.WriteString("\n")
1069 }
1070 sb.WriteString(tb.Text)
1071 }
1072 }
1073 if text := sb.String(); strings.TrimSpace(text) != "" {
1074 go i.telegramBridge.OnAssistantText(text)
1075 }
1076 }
1077}
1078func (i *Interactive) runStreamPacer(ctx context.Context) {
1079 t := time.NewTicker(paintPaceInterval)
1080 defer t.Stop()
1081 for {
1082 select {
1083 case <-ctx.Done():
1084 return
1085 case <-t.C:
1086 i.mu.Lock()
1087 if len(i.streamPending) == 0 {
1088 // EvAssistantMessage already fired but the pacer
1089 // was still draining a tick ago. Everything is now
1090 // painted; clear the streaming flags so the next
1091 // redraw shows the finalised transcript message
1092 // and hides the streaming overlay.
1093 if i.streamFlushPending {
1094 i.streamFlushPending = false
1095 i.streaming.Reset()
1096 i.streamOn = false
1097 i.openAllToolGatesLocked()
1098 i.mu.Unlock()
1099 i.invalidate()
1100 continue
1101 }
1102 i.mu.Unlock()
1103 continue
1104 }
1105 n := paintPaceRate
1106 if n > len(i.streamPending) {
1107 n = len(i.streamPending)
1108 }
1109 i.streaming.WriteString(string(i.streamPending[:n]))
1110 i.streamPending = i.streamPending[n:]
1111 i.mu.Unlock()
1112 i.invalidate()
1113 }
1114 }
1115}