From 1c190a3925cd140aa06fc424cfa3e4a6a2e04d34 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 2 Apr 2026 00:52:57 -0400 Subject: [PATCH] Wire AgentCycleState through runner and TUI Runner owns AgentCycleState, calls trigger() on each user message instead of the old run_hook() JSON round-trip. Sends AgentUpdate messages to TUI after each cycle. TUI F2 screen reads agent state from messages instead of scanning the filesystem on every frame. HookSession::from_fields() lets poc-agent construct sessions without JSON serialization. Co-Authored-By: Proof of Concept --- src/agent/runner.rs | 27 +++++++++++++++----------- src/agent/tui.rs | 42 ++++++++++++++++------------------------ src/agent/ui_channel.rs | 3 +++ src/session.rs | 10 ++++++++++ src/subconscious/hook.rs | 4 ++-- 5 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/agent/runner.rs b/src/agent/runner.rs index 52f5b04..3aa2f75 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -74,6 +74,8 @@ pub struct Agent { pub shared_context: SharedContextState, /// Stable session ID for memory-search dedup across turns. session_id: String, + /// Agent orchestration state (surface-observe, journal, reflect). + pub agent_cycles: crate::subconscious::hook::AgentCycleState, } impl Agent { @@ -96,6 +98,7 @@ impl Agent { loaded_nodes: Vec::new(), }; let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")); + let agent_cycles = crate::subconscious::hook::AgentCycleState::new(&session_id); let mut agent = Self { client, messages: Vec::new(), @@ -109,6 +112,7 @@ impl Agent { context, shared_context, session_id, + agent_cycles, }; // Load recent journal entries at startup for orientation @@ -128,20 +132,20 @@ impl Agent { agent } - /// Run memory search for a given event, returning any output to inject. - /// Direct library call — no subprocess needed since everything is one crate. - fn run_hook(&self, event: &str, _prompt: &str) -> Option { + /// Run agent orchestration cycle and return formatted output to inject. + fn run_agent_cycle(&mut self) -> Option { let transcript_path = self.conversation_log.as_ref() .map(|l| l.path().to_string_lossy().to_string()) .unwrap_or_default(); - let hook_input = serde_json::json!({ - "hook_event_name": event, - "session_id": self.session_id, - "transcript_path": transcript_path, - }); + let session = crate::session::HookSession::from_fields( + self.session_id.clone(), + transcript_path, + "UserPromptSubmit".into(), + ); - let text = crate::memory_search::run_hook(&hook_input.to_string()); + self.agent_cycles.trigger(&session); + let text = crate::subconscious::hook::format_agent_output(&self.agent_cycles.last_output); if text.trim().is_empty() { None } else { @@ -231,14 +235,15 @@ impl Agent { ui_tx: &UiSender, target: StreamTarget, ) -> Result { - // Run poc-hook (memory search, notifications, context check) - if let Some(hook_output) = self.run_hook("UserPromptSubmit", user_input) { + // Run agent orchestration cycle (surface-observe, reflect, journal) + if let Some(hook_output) = self.run_agent_cycle() { let enriched = format!("{}\n\n\n{}\n", user_input, hook_output); self.push_message(Message::user(enriched)); } else { self.push_message(Message::user(user_input)); } + let _ = ui_tx.send(UiMessage::AgentUpdate(self.agent_cycles.agents.clone())); let mut overflow_retries: u32 = 0; let mut empty_retries: u32 = 0; diff --git a/src/agent/tui.rs b/src/agent/tui.rs index d9162af..bb3a1a8 100644 --- a/src/agent/tui.rs +++ b/src/agent/tui.rs @@ -348,6 +348,8 @@ pub struct App { agent_selected: usize, /// Agent screen: viewing log for selected agent. agent_log_view: bool, + /// Agent state from last cycle update. + agent_state: Vec, } /// Overlay screens toggled by F-keys. @@ -410,6 +412,7 @@ impl App { shared_context, agent_selected: 0, agent_log_view: false, + agent_state: Vec::new(), } } @@ -508,6 +511,9 @@ impl App { UiMessage::ContextInfoUpdate(info) => { self.context_info = Some(info); } + UiMessage::AgentUpdate(agents) => { + self.agent_state = agents; + } } } @@ -1047,41 +1053,27 @@ impl App { lines.push(Line::raw("")); for (i, &name) in AGENT_NAMES.iter().enumerate() { - let agent_dir = output_dir.join(name); - let live = crate::subconscious::knowledge::scan_pid_files(&agent_dir, 0); let selected = i == self.agent_selected; - let prefix = if selected { "▸ " } else { " " }; let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() }; - if live.is_empty() { - lines.push(Line::from(vec![ - Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Gray)), - Span::styled("○ idle", bg.fg(Color::DarkGray)), - ])); - } else { - for (phase, pid) in &live { + let agent = self.agent_state.iter().find(|a| a.name == name); + + match agent.and_then(|a| a.pid) { + Some(pid) => { + let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); lines.push(Line::from(vec![ Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Green)), Span::styled("● ", bg.fg(Color::Green)), Span::styled(format!("pid {} phase: {}", pid, phase), bg), ])); } - } - } - - // Recent output - lines.push(Line::raw("")); - lines.push(Line::styled("── Recent Activity ──", section)); - lines.push(Line::raw("")); - - for &name in AGENT_NAMES { - let agent_dir = output_dir.join(name); - if let Some((file, ago)) = Self::most_recent_file(&agent_dir) { - lines.push(Line::from(vec![ - Span::styled(format!(" {:<20}", name), dim), - Span::raw(format!("{} ({})", file, ago)), - ])); + None => { + lines.push(Line::from(vec![ + Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Gray)), + Span::styled("○ idle", bg.fg(Color::DarkGray)), + ])); + } } } diff --git a/src/agent/ui_channel.rs b/src/agent/ui_channel.rs index f986755..7d7b426 100644 --- a/src/agent/ui_channel.rs +++ b/src/agent/ui_channel.rs @@ -124,6 +124,9 @@ pub enum UiMessage { /// Context loading details — stored for the debug screen (Ctrl+D). ContextInfoUpdate(ContextInfo), + + /// Agent cycle state update — refreshes the F2 agents screen. + AgentUpdate(Vec), } /// Sender that fans out to both the TUI (mpsc) and observers (broadcast). diff --git a/src/session.rs b/src/session.rs index 853ac91..cbae36c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -40,6 +40,16 @@ impl HookSession { self.state_dir.join(format!("{}-{}", prefix, self.session_id)) } + /// Construct directly from fields. + pub fn from_fields(session_id: String, transcript_path: String, hook_event: String) -> Self { + HookSession { + state_dir: Self::sessions_dir(), + session_id, + transcript_path, + hook_event, + } + } + /// Load from a session ID string pub fn from_id(session_id: String) -> Option { if session_id.is_empty() { return None; } diff --git a/src/subconscious/hook.rs b/src/subconscious/hook.rs index 9f1e2ab..e15a9a0 100644 --- a/src/subconscious/hook.rs +++ b/src/subconscious/hook.rs @@ -135,11 +135,11 @@ pub struct AgentCycleOutput { } /// Per-agent runtime state visible to the TUI. +#[derive(Clone, Debug)] pub struct AgentInfo { pub name: &'static str, pub pid: Option, pub phase: Option, - pub last_log: Option, } /// Persistent state for the agent orchestration cycle. @@ -164,7 +164,7 @@ impl AgentCycleState { .create(true).append(true).open(log_path).ok(); let agents = AGENT_CYCLE_NAMES.iter() - .map(|&name| AgentInfo { name, pid: None, phase: None, last_log: None }) + .map(|&name| AgentInfo { name, pid: None, phase: None }) .collect(); AgentCycleState {