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