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