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