Skip to main content

trustee_core/
session.rs

1//! Session state — core agent session without any UI concerns.
2//!
3//! This struct holds all the state shared between frontends (TUI, API, Web):
4//! output lines, input, workflow state, config, resume info, MCP servers, etc.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use abk::cli::ResumeInfo;
13use abk::context::RunContext;
14
15use std::sync::atomic::{AtomicU8, Ordering};
16
17use crate::types::{
18    AutoHandoffConfig, BuildInfo, McpServerInfo, McpServerStatus,
19    TuiMessage, WorkflowState,
20};
21
22/// Core session state for the Trustee agent.
23///
24/// Holds all state that is independent of the presentation layer (TUI, API, Web).
25/// Frontend crates compose this struct and add their own UI-specific fields.
26pub struct Session {
27    /// Input buffer for user commands
28    pub input: String,
29    /// Output log lines
30    pub output_lines: Vec<String>,
31    /// Sender for messages from async workflows (clone and pass to workflow runners)
32    pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
33    /// Current workflow lifecycle state
34    pub workflow_state: WorkflowState,
35    /// Configuration TOML for ABK workflows
36    pub config_toml: Option<String>,
37    /// Secrets for ABK workflows
38    pub secrets: Option<HashMap<String, String>>,
39    /// Build info for ABK workflows
40    pub build_info: Option<BuildInfo>,
41    /// Resume info from the last completed task for session continuity
42    pub resume_info: Option<ResumeInfo>,
43    /// Saved resume_info before execute_command consumes it; restored if task
44    /// is cancelled before producing a real checkpoint (mistake-ENTER recovery).
45    pub backup_resume_info: Option<ResumeInfo>,
46    /// Latest todo list from LLM todowrite tool
47    pub todo_lines: Vec<String>,
48    /// Cancellation token for aborting the current workflow
49    pub cancel_token: CancellationToken,
50    /// Command buffered by user during cancellation wind-down.
51    pub pending_command: Option<String>,
52    /// Whether a session handoff (Ctrl+H) should fire once the current workflow cancels.
53    pub handoff_pending: bool,
54    /// In-flight spinner entries: (tool_name, output_lines_index, hint).
55    pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
56    /// Current context token count (updated from ApiCallStarted events).
57    pub current_context_tokens: usize,
58    /// Auto-handoff configuration parsed from [tui.auto_handoff].
59    pub auto_handoff: AutoHandoffConfig,
60    /// MCP server statuses received from agent init
61    pub mcp_servers: Vec<McpServerInfo>,
62    /// Whether the session should quit
63    pub should_quit: bool,
64    /// Whether auto-scroll is enabled (follows new output)
65    pub auto_scroll: bool,
66
67    // --- TMU Phase 1: Stateless Core ---
68    /// Agent name for checkpoint/token paths (replaces ABK_AGENT_NAME env var).
69    /// Defaults to "trustee". Set from config at startup.
70    pub agent_name: String,
71    /// Per-session token store (None = FileTokenStore fallback).
72    /// When set, MCP credential flows use this instead of file-based storage.
73    pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
74
75    // --- Project/Session Identity (backward compatible, all None = old behavior) ---
76    /// Storage partition key (replaces path hash). None = hash(working_dir)
77    pub project_id: Option<String>,
78    /// Human-readable project name. None = directory name
79    pub project_name: Option<String>,
80    /// Storage directory name (replaces timestamp slug). None = auto-generate
81    pub session_id: Option<String>,
82    /// Human-readable session name. None = no description
83    pub session_name: Option<String>,
84    /// Per-user home directory for checkpoint storage. None = default ~/.{agent_name}/
85    /// Set to ~/.trustee/users/{user_hash}/ for per-user isolation in web mode.
86    pub home_dir: Option<std::path::PathBuf>,
87
88    /// Concurrency permit — held while a workflow is running.
89    /// When the workflow completes (state → Idle), this is dropped,
90    /// releasing the permit back to the global semaphore.
91    /// None when no workflow is running or when concurrency limiting is disabled.
92    pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
93
94    /// Optional agent identity content (e.g. fetched from Fame).
95    /// When set, prepended to `lifecycle.system_template` in the config
96    /// clone inside `execute_command()` and `trigger_handoff()`.
97    /// None = use the config's default system template.
98    pub identity: Option<String>,
99}
100
101impl Session {
102    /// Create a new Session with default state and a fresh message channel.
103    ///
104    /// Returns `(Session, Receiver)` so the caller can own the receiver
105    /// without locking the session (prevents deadlock in async drain loops).
106    pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
107        let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
108        let session = Self {
109            input: String::new(),
110            output_lines: Vec::new(),
111            workflow_tx,
112            workflow_state: WorkflowState::Idle,
113            config_toml: None,
114            secrets: None,
115            build_info: None,
116            resume_info: None,
117            backup_resume_info: None,
118            todo_lines: Vec::new(),
119            cancel_token: CancellationToken::new(),
120            pending_command: None,
121            handoff_pending: false,
122            pending_tool_lines: Vec::new(),
123            current_context_tokens: 0,
124            auto_handoff: AutoHandoffConfig::default(),
125            mcp_servers: Vec::new(),
126            should_quit: false,
127            auto_scroll: true,
128            agent_name: "trustee".to_string(),
129            token_store: None,
130            project_id: None,
131            project_name: None,
132            session_id: None,
133            session_name: None,
134            home_dir: None,
135            workflow_permit: None,
136            identity: None,
137        };
138        (session, workflow_rx)
139    }
140
141    /// Parse auto-handoff configuration from the stored config TOML.
142    pub fn parse_auto_handoff_config(&mut self) {
143        if let Some(ref config_toml) = self.config_toml {
144            self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
145        }
146    }
147
148    /// Handle messages from async workflows.
149    ///
150    /// This processes all workflow lifecycle events, output updates, and state transitions.
151    /// Returns `true` if the caller should check for pending commands/handoffs after.
152    pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
153        match msg {
154            TuiMessage::WorkflowCancelled => {
155                self.output_lines.push("⏹ Workflow cancelled".to_string());
156                self.output_lines.push("".to_string());
157                self.workflow_state = WorkflowState::Cancelling;
158            }
159            TuiMessage::OutputLine(line) => {
160                self.output_lines.push(line);
161            }
162            TuiMessage::StreamDelta(delta) => {
163                if let Some(last) = self.output_lines.last_mut() {
164                    last.push_str(&delta);
165                } else {
166                    self.output_lines.push(delta);
167                }
168            }
169            TuiMessage::ReasoningDelta(delta) => {
170                if let Some(last) = self.output_lines.last_mut() {
171                    if !last.starts_with('\x01') {
172                        last.insert(0, '\x01');
173                    }
174                    last.push_str(&delta);
175                } else {
176                    self.output_lines.push(format!("\x01{}", delta));
177                }
178            }
179            TuiMessage::WorkflowCompleted => {
180                self.output_lines.push("✓ Workflow completed".to_string());
181                self.output_lines.push("".to_string());
182                if self.workflow_state == WorkflowState::Running {
183                    self.workflow_state = WorkflowState::Cancelling;
184                }
185            }
186            TuiMessage::WorkflowError(err) => {
187                self.output_lines.push(format!("✗ Error: {}", err));
188                self.output_lines.push("".to_string());
189                if self.workflow_state == WorkflowState::Running {
190                    self.workflow_state = WorkflowState::Cancelling;
191                }
192            }
193            TuiMessage::TodoUpdate(content) => {
194                self.todo_lines = content.lines().map(|l| l.to_string()).collect();
195            }
196            TuiMessage::ToolPending { tool_name, hint } => {
197                let label = match &hint {
198                    Some(h) => format!("⠋ {} {}", tool_name, h),
199                    None => format!("⠋ {}", tool_name),
200                };
201                let idx = self.output_lines.len();
202                self.output_lines.push(label);
203                self.pending_tool_lines.push((tool_name, idx, hint));
204            }
205            TuiMessage::ToolDone { tool_name, success, hint } => {
206                let status = if success { "✓" } else { "✗" };
207                if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
208                    let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
209                    let h = hint.or(pending_hint);
210                    let label = match &h {
211                        Some(h) => format!("{} {} {}", status, tool_name, h),
212                        None => format!("{} {}", status, tool_name),
213                    };
214                    if idx < self.output_lines.len() {
215                        self.output_lines[idx] = label;
216                        return;
217                    }
218                    self.output_lines.push(label);
219                } else {
220                    let label = match &hint {
221                        Some(h) => format!("{} {} {}", status, tool_name, h),
222                        None => format!("{} {}", status, tool_name),
223                    };
224                    self.output_lines.push(label);
225                }
226            }
227            TuiMessage::ResumeInfo(info) => {
228                if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
229                    self.resume_info = self.backup_resume_info.take();
230                } else if info.is_some() {
231                    // Only overwrite with a valid resume_info.
232                    // Don't let ResumeInfo(None) clobber a valid Some that was
233                    // set by an earlier incremental checkpoint message — this
234                    // happens when the error/cancel path fails to create a
235                    // final checkpoint but earlier checkpoints exist.
236                    self.resume_info = info;
237                    self.backup_resume_info = None;
238                }
239                // If info is None and we're not cancelling, keep existing resume_info.
240
241                // Capture session_id from resume_info so it persists for the
242                // lifetime of this conversation. This is immutable — once ABK
243                // assigns it, we never change it.
244                if let Some(ref ri) = self.resume_info {
245                    if self.session_id.is_none() {
246                        self.session_id = Some(ri.session_id.clone());
247                    }
248                }
249
250                if self.workflow_state == WorkflowState::Cancelling {
251                    self.workflow_state = WorkflowState::Idle;
252                    // Release the concurrency permit when the workflow finishes.
253                    self.workflow_permit = None;
254                }
255                if self.resume_info.is_some() {
256                    if std::env::var("RUST_LOG")
257                        .map(|v| v.to_lowercase().contains("debug"))
258                        .unwrap_or(false)
259                    {
260                        self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
261                    }
262                }
263                if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
264                    self.handoff_pending = false;
265                    self.trigger_handoff(String::new());
266                } else if let Some(cmd) = self.pending_command.take() {
267                    self.input = cmd;
268                    self.execute_command();
269                }
270            }
271            TuiMessage::ContextTokensUpdated(count) => {
272                self.current_context_tokens = count;
273                if self.auto_handoff.enabled
274                    && count >= self.auto_handoff.context_threshold
275                    && self.workflow_state == WorkflowState::Running
276                    && !self.handoff_pending
277                    && self.resume_info.is_some()
278                {
279                    self.handoff_pending = true;
280                    self.cancel_token.cancel();
281                    self.workflow_state = WorkflowState::Cancelling;
282                    self.output_lines.push(format!(
283                        "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
284                        count, self.auto_handoff.context_threshold
285                    ));
286                }
287            }
288            TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
289                let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
290                if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
291                    existing.status = status;
292                    existing.tool_count = tool_count;
293                    existing.error = error;
294                } else {
295                    self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
296                }
297            }
298            TuiMessage::HandoffReady(briefing) => {
299                self.workflow_state = WorkflowState::Idle;
300                self.resume_info = None;
301                // Clear session identity so execute_command generates fresh
302                // session_id/session_name for the new post-handoff session.
303                self.session_id = None;
304                self.session_name = None;
305                self.input = briefing;
306                self.execute_command();
307            }
308            TuiMessage::HandoffFailed => {
309                // Briefing unavailable — session continuity was already restored
310                // by the ResumeInfo(Some) that precedes this message. Return to
311                // Idle so the user can retry or continue manually (Bug 5/8).
312                self.workflow_state = WorkflowState::Idle;
313                self.output_lines.push(
314                    "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
315                );
316                self.output_lines.push("".to_string());
317            }
318            TuiMessage::SessionTitleUpdated(title) => {
319                // LLM-generated title has arrived — update the session name.
320                self.session_name = Some(title);
321            }
322        }
323        if self.auto_scroll {
324            // Signal to frontend that it should scroll to bottom.
325            // Frontend reads auto_scroll flag directly.
326        }
327    }
328
329    /// Execute the current command in the input buffer.
330    ///
331    /// Spawns an async ABK workflow task, clears the input buffer, and sets
332    /// workflow_state to Running.
333    pub fn execute_command(&mut self) {
334        let command = self.input.trim().to_string();
335
336        if self.workflow_state != WorkflowState::Idle {
337            self.pending_command = Some(command);
338            self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
339            self.input.clear();
340            return;
341        }
342
343        let is_continuation = self.resume_info.is_some();
344
345        if !is_continuation {
346            self.output_lines.clear();
347            // Auto-derive session_id and session_name from the first command
348            // if not explicitly set. The session_id uses timestamp + UUID suffix
349            // (session_YYYY_MM_DD_HH_MM_{uuid8}) for uniqueness across all
350            // interfaces. The session_name is a human-readable display name
351            // (truncated command text).
352            if self.session_id.is_none() {
353                let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
354                let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
355                let uuid8 = &uuid_suffix[..8];
356                self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
357            }
358            if self.session_name.is_none() {
359                let derived = if command.len() > 80 {
360                    format!("{}...", &command[..77])
361                } else {
362                    command.clone()
363                };
364                self.session_name = Some(derived);
365            }
366        }
367
368        self.output_lines.push(format!("> {}", command));
369
370        let config_toml = match &self.config_toml {
371            Some(c) => c.clone(),
372            None => {
373                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
374                self.output_lines.push("".to_string());
375                return;
376            }
377        };
378
379        // Inject identity into the config clone if present.
380        let config_toml = inject_identity(config_toml, &self.identity);
381
382        let secrets = self.secrets.clone().unwrap_or_default();
383        // Clone secrets for post-completion title generation (secrets are moved into run_task below)
384        let title_secrets = secrets.clone();
385        let build_info = self.build_info.clone();
386        let tx = self.workflow_tx.clone();
387
388        let agent_name = self.agent_name.clone();
389        let token_store = self.token_store.clone();
390        let project_id = self.project_id.clone();
391        let project_name = self.project_name.clone();
392        let session_id = self.session_id.clone();
393        let session_name = self.session_name.clone();
394        let home_dir = self.home_dir.clone();
395
396        self.backup_resume_info = self.resume_info.clone();
397        let resume_info = self.resume_info.take();
398
399        self.workflow_state = WorkflowState::Running;
400        self.auto_scroll = true;
401
402        self.cancel_token = CancellationToken::new();
403        let child_token = self.cancel_token.clone();
404
405        let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
406
407        let resume_forward_tx = tx.clone();
408        tokio::spawn(async move {
409            while let Some(info) = resume_rx.recv().await {
410                resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
411            }
412        });
413
414        tokio::spawn(async move {
415            let tui_sink: abk::orchestration::output::SharedSink =
416                Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
417
418            // Build RunContext from session fields for stateless operation
419            let mut run_ctx = RunContext::new()
420                .with_agent_name(agent_name.clone());
421
422            // Set home_dir for per-user isolation if provided
423            if let Some(ref dir) = home_dir {
424                run_ctx = run_ctx.with_home_dir(dir.clone());
425            }
426
427            // Set project identity if any field is provided
428            if project_id.is_some() || project_name.is_some() {
429                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
430                    id: project_id.unwrap_or_else(|| "default".to_string()),
431                    name: project_name,
432                });
433            }
434
435            // Set session identity if any field is provided
436            if session_id.is_some() || session_name.is_some() {
437                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
438                    id: session_id.unwrap_or_else(|| "default".to_string()),
439                    name: session_name,
440                });
441            }
442
443            #[cfg(feature = "registry-mcp-token")]
444            {
445                if let Some(ref ts) = token_store {
446                    run_ctx = run_ctx.with_token_store(ts.clone());
447                }
448            }
449
450            // Run the entire workflow inside a TUI-mode scope and a
451            // per-task logger scope. This replaces the old process-global
452            // set_tui_mode()/init_global_logger() mutations with task-local
453            // scopes, enabling safe concurrent multi-user operation.
454            let scope_logger = {
455                abk::observability::Logger::with_agent_name(
456                    None::<&std::path::Path>,
457                    Some("INFO"),
458                    Some(&agent_name),
459                ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
460            };
461
462            let result = abk::observability::with_logger(scope_logger, async {
463                abk::observability::with_tui_mode(true, async {
464                    abk::cli::run_task_from_raw_config(
465                        &config_toml,
466                        secrets,
467                        build_info,
468                        &command,
469                        Some(tui_sink),
470                        resume_info,
471                        Some(resume_tx),
472                        Some(child_token),
473                        Some(&run_ctx),
474                    )
475                    .await
476                })
477                .await
478            })
479            .await;
480
481            let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
482                success: false,
483                error: Some(e.to_string()),
484                // resume_info will be None here, but the on_checkpoint channel
485                // may have already delivered a valid ResumeInfo via TuiMessage.
486                // The ResumeInfo handler now ignores None when a valid Some exists,
487                // so this None won't clobber the earlier incremental checkpoint.
488                resume_info: None,
489            });
490
491            let msg = if task_result.success {
492                TuiMessage::WorkflowCompleted
493            } else {
494                TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
495            };
496
497            // Capture session_id from resume_info before it's moved into the channel
498            let title_session_id = task_result.resume_info
499                .as_ref()
500                .map(|ri| ri.session_id.clone());
501
502            tx.send(msg).ok();
503            tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
504
505            // Solution B: After successful completion, spawn a lightweight LLM call
506            // to generate a descriptive session title. The title is:
507            // 1. Persisted to session_metadata.json on disk via persist_session_title
508            // 2. Sent via SessionTitleUpdated message (updates in-memory session_name)
509            // Only fires if the title hasn't been LLM-set yet.
510            // Fire-and-forget — errors don't affect the session.
511            if task_result.success {
512                let title_tx = tx.clone();
513                let title_config = config_toml.clone();
514                let title_command = command.clone();
515                let title_ctx = run_ctx.clone();
516
517                tokio::spawn(async move {
518                    // Only generate titles for truly fresh sessions.
519                    // Check the disk: if session already has checkpoints from prior
520                    // runs, or description is already set, skip.
521                    if let Some(ref sid) = title_session_id {
522                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
523                            return;
524                        }
525                    } else {
526                        return; // No session ID — can't safely persist
527                    }
528
529                    // Small delay to ensure checkpoint metadata writes complete first
530                    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
531
532                    // Re-check after delay (checkpoint may have written metadata)
533                    if let Some(ref sid) = title_session_id {
534                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
535                            return;
536                        }
537                    }
538
539                    match abk::cli::generate_session_title(
540                        &title_config,
541                        title_secrets,
542                        &title_command,
543                    )
544                    .await
545                    {
546                        Ok(Some(title)) => {
547                            // Persist to disk + remote
548                            if let Some(ref sid) = title_session_id {
549                                if let Err(e) = abk::cli::persist_session_title(
550                                    &title_ctx,
551                                    &title_config,
552                                    sid,
553                                    &title,
554                                ).await {
555                                    let _ = e; // non-fatal
556                                }
557                            }
558                            // Update in-memory session name for live WS clients
559                            title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
560                        }
561                        Ok(None) => {}
562                        Err(e) => { let _ = e; }
563                    }
564                });
565            }
566        });
567
568        self.input.clear();
569    }
570
571    /// Request a session handoff through the safe state-machine entry point.
572    ///
573    /// Mirrors the TUI Ctrl+H behavior:
574    /// - Idle → trigger the handoff immediately
575    /// - Running → cancel the workflow, fire handoff once it stops
576    /// - Cancelling → queue handoff for when it stops
577    ///
578    /// This prevents any caller (web button, API, future MCP tool) from
579    /// spawning a briefing task concurrently with a running workflow
580    /// (Bugs 1/2/5).
581    pub fn request_handoff(&mut self, hint: String) {
582        match self.workflow_state {
583            WorkflowState::Idle => self.trigger_handoff(hint),
584            WorkflowState::Running => {
585                self.cancel_token.cancel();
586                self.workflow_state = WorkflowState::Cancelling;
587                self.handoff_pending = true;
588                // Broadcast via the message channel so live UI (web/TUI) sees
589                // the status immediately. A direct output_lines.push() would
590                // only appear after a page refresh (output_lines is served via
591                // the /api/v1/session poll, not pushed over WebSocket).
592                self.workflow_tx
593                    .send(TuiMessage::OutputLine("⏹ Cancelling before handoff...".to_string()))
594                    .ok();
595            }
596            WorkflowState::Cancelling => {
597                self.handoff_pending = true;
598            }
599        }
600    }
601
602    /// Trigger a session handoff.
603    ///
604    /// Runs a single LLM call using the current session's resume_info to generate
605    /// a briefing. On completion, sends `TuiMessage::HandoffReady(briefing)`.
606    ///
607    /// This is the internal Idle-only entry point. Callers that may be invoked
608    /// while a workflow is running should use [`Session::request_handoff`].
609    pub fn trigger_handoff(&mut self, hint: String) {
610        // Belt-and-braces guard: never run a briefing concurrently with a
611        // workflow (Bug 1). request_handoff handles the Running/Cancelling
612        // states; direct callers must be Idle.
613        if self.workflow_state != WorkflowState::Idle {
614            self.output_lines
615                .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
616            return;
617        }
618        if self.resume_info.is_none() {
619            self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
620            return;
621        }
622
623        let config_toml = match &self.config_toml {
624            Some(c) => c.clone(),
625            None => {
626                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
627                return;
628            }
629        };
630
631        // Inject identity into the config clone if present.
632        let config_toml = inject_identity(config_toml, &self.identity);
633
634        let tx = self.workflow_tx.clone();
635
636        let agent_name = self.agent_name.clone();
637        let project_id = self.project_id.clone();
638        let project_name = self.project_name.clone();
639        let session_id = self.session_id.clone();
640        let session_name = self.session_name.clone();
641        let home_dir = self.home_dir.clone();
642
643        // Preserve resume_info so a failed briefing can restore the session
644        // instead of losing all continuity (Bug 4/8).
645        let backup_resume_info = self.resume_info.clone();
646        let resume_info = match self.resume_info.take() {
647            Some(ri) => ri,
648            None => {
649                // Already checked is_none() above — defensive only.
650                self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
651                return;
652            }
653        };
654
655        self.workflow_state = WorkflowState::Running;
656        self.auto_scroll = true;
657        self.cancel_token = CancellationToken::new();
658
659        // Broadcast via the message channel so live UI (web/TUI) sees the
660        // status line immediately. A direct output_lines.push() would only
661        // appear after a page refresh (output_lines is served via the
662        // /api/v1/session poll, not pushed over WebSocket). The drain task
663        // pushes this line to output_lines (via handle_workflow_message) AND
664        // broadcasts StateChanged(Running) + OutputLine to WS clients.
665        self.workflow_tx
666            .send(TuiMessage::OutputLine("🔀 Generating session handoff briefing...".to_string()))
667            .ok();
668
669        tokio::spawn(async move {
670            let base = "Output a session handoff briefing in at most 300 lines. \
671                 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
672                 project/repository being worked on (e.g. /Projects/Foo/bar — never \
673                 omit the leading path), all project/task/workstream UUIDs referenced, \
674                 every file created or modified with its full absolute path, all \
675                 commands run and their outcomes, the current state of the work, any \
676                 blockers, and the exact next action to take. \
677                 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
678            let prompt = if hint.is_empty() {
679                base.to_string()
680            } else {
681                format!("{base}\n\nIn the briefing also consider: {hint}")
682            };
683
684            // Build RunContext for stateless operation — same fields as execute_command
685            let mut run_ctx = RunContext::new()
686                .with_agent_name(agent_name.clone());
687
688            // Set home_dir for per-user isolation
689            if let Some(ref dir) = home_dir {
690                run_ctx = run_ctx.with_home_dir(dir.clone());
691            }
692
693            // Set project identity if any field is provided
694            if project_id.is_some() || project_name.is_some() {
695                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
696                    id: project_id.unwrap_or_else(|| "default".to_string()),
697                    name: project_name,
698                });
699            }
700
701            // Set session identity if any field is provided
702            if session_id.is_some() || session_name.is_some() {
703                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
704                    id: session_id.unwrap_or_else(|| "default".to_string()),
705                    name: session_name,
706                });
707            }
708
709            // Run inside per-task logger + TUI-mode scope (task-local, not process-global)
710            let scope_logger = abk::observability::Logger::with_agent_name(
711                None,
712                Some("INFO"),
713                Some(&agent_name),
714            ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
715
716            // Single direct LLM call — no agentic workflow loop. This is the fix
717            // for the session-bricking bug: the full workflow ran tools, looped
718            // on the large history, and never completed.
719            let result = abk::observability::with_logger(scope_logger, async {
720                abk::observability::with_tui_mode(true, async {
721                    abk::cli::generate_handoff_briefing(
722                        &config_toml,
723                        &run_ctx,
724                        &resume_info,
725                        &prompt,
726                    )
727                    .await
728                })
729                .await
730            })
731            .await;
732
733            match result {
734                Ok(briefing) if !briefing.trim().is_empty() => {
735                    tx.send(TuiMessage::HandoffReady(briefing)).ok();
736                }
737                _ => {
738                    // Briefing unavailable (LLM failure, truncation, cancel).
739                    // Restore session continuity and surface the failure —
740                    // NEVER auto-execute a garbage fallback string (Bug 8).
741                    tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
742                    tx.send(TuiMessage::HandoffFailed).ok();
743                }
744            }
745        });
746    }
747}
748
749impl Default for Session {
750    fn default() -> Self {
751        Self::new().0
752    }
753}
754
755/// Prepend agent identity to `lifecycle.system_template` in the config TOML.
756///
757/// If `identity` is `None` or empty, returns the config unchanged.
758/// On parse/serialize failure, returns the config unchanged (best-effort).
759fn inject_identity(config_toml: String, identity: &Option<String>) -> String {
760    let Some(identity) = identity.as_ref().filter(|s| !s.is_empty()) else {
761        return config_toml;
762    };
763
764    let Ok(mut table) = config_toml.parse::<toml::Value>() else {
765        return config_toml;
766    };
767
768    let lifecycle = table
769        .get_mut("lifecycle")
770        .and_then(|v| v.as_table_mut());
771
772    if let Some(lifecycle) = lifecycle {
773        let existing = lifecycle
774            .get("system_template")
775            .and_then(|v| v.as_str())
776            .unwrap_or("");
777        let combined = format!("{}\n\n{}", identity, existing);
778        lifecycle.insert(
779            "system_template".to_string(),
780            toml::Value::String(combined),
781        );
782    } else {
783        // No [lifecycle] section — create one with just the identity as template.
784        let mut ltable = toml::value::Table::new();
785        ltable.insert(
786            "system_template".to_string(),
787            toml::Value::String(identity.clone()),
788        );
789        if let Some(table) = table.as_table_mut() {
790            table.insert("lifecycle".to_string(), toml::Value::Table(ltable));
791        }
792    }
793
794    toml::to_string(&table).unwrap_or(config_toml)
795}
796
797/// A sink that forwards ABK `OutputEvent`s to the message channel.
798///
799/// Includes a 3-state atomic state machine (IDLE/REASONING/CONTENT) that
800/// inserts blank separator lines when transitioning between reasoning and
801/// content streams, so the frontend can distinguish them visually.
802pub struct TuiForwardSink {
803    tx: mpsc::UnboundedSender<TuiMessage>,
804    stream_state: AtomicU8,
805}
806
807/// Stream state machine constants.
808const STREAM_IDLE: u8 = 0;
809const STREAM_REASONING: u8 = 1;
810const STREAM_CONTENT: u8 = 2;
811
812impl TuiForwardSink {
813    pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
814        Self {
815            tx,
816            stream_state: AtomicU8::new(STREAM_IDLE),
817        }
818    }
819}
820
821impl abk::orchestration::output::OutputSink for TuiForwardSink {
822    fn emit(&self, event: abk::orchestration::output::OutputEvent) {
823        use abk::orchestration::output::OutputEvent;
824
825        let msg = match event {
826            OutputEvent::StreamingChunk { delta } => {
827                if delta.is_empty() {
828                    return;
829                }
830                let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
831                if prev != STREAM_CONTENT {
832                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
833                }
834                let _ = self.tx.send(TuiMessage::StreamDelta(delta));
835                return;
836            }
837
838            OutputEvent::LlmResponse { text, model } => {
839                TuiMessage::OutputLine(format!("[{}] {}", model, text))
840            }
841
842            OutputEvent::Info { message } => {
843                // Suppress noisy/no-value messages from ABK
844                if message.contains("API call completed successfully") {
845                    return;
846                }
847                TuiMessage::OutputLine(message)
848            }
849
850            OutputEvent::WorkflowStarted { task_description } => {
851                TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
852            }
853
854            OutputEvent::WorkflowCompleted { reason, iterations } => {
855                TuiMessage::OutputLine(format!(
856                    "✅ Workflow completed after {} iterations: {}",
857                    iterations, reason
858                ))
859            }
860
861            OutputEvent::IterationStarted { iteration, context_tokens } => {
862                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
863                TuiMessage::OutputLine(format!(
864                    "📡 Iteration {} | Context = {} tokens",
865                    iteration, context_tokens
866                ))
867            }
868
869            OutputEvent::ApiCallStarted {
870                call_number,
871                model,
872                tool_count,
873                streaming,
874                context_tokens,
875                tool_tokens,
876            } => {
877                let mode = if streaming { "Streaming" } else { "Non-streaming" };
878                let total = context_tokens + tool_tokens;
879                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
880                // Blank line separator before each API call for readability
881                let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
882                TuiMessage::OutputLine(format!(
883                    "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
884                    call_number, total, context_tokens, tool_tokens, mode, model, tool_count
885                ))
886            }
887
888            OutputEvent::ToolsExecuting { tool_names, hints } => {
889                for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
890                    let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
891                }
892                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
893                return;
894            }
895
896            OutputEvent::ToolCompleted {
897                tool_name,
898                success,
899                content,
900                description,
901            } => {
902                if tool_name == "todowrite" && success {
903                    let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
904                }
905                let hint = description;
906                let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
907                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
908                return;
909            }
910
911            OutputEvent::Error { message, context } => {
912                if let Some(ctx) = context {
913                    TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
914                } else {
915                    TuiMessage::OutputLine(format!("❌ Error: {}", message))
916                }
917            }
918
919            OutputEvent::ReasoningChunk { delta } => {
920                if delta.is_empty() {
921                    return;
922                }
923                let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
924                if prev != STREAM_REASONING {
925                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
926                }
927                let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
928                return;
929            }
930
931            OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
932                let _ = self.tx.send(TuiMessage::McpServerStatus {
933                    name,
934                    connected,
935                    tool_count,
936                    error,
937                });
938                return;
939            }
940        };
941
942        self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
943        let _ = self.tx.send(msg);
944    }
945}