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