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