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