use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::console::{icon_info, icon_ok, icon_play, icon_warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub model: String, pub temperature: f32,
pub max_tokens: u32,
pub tools: Vec<String>,
pub max_iterations: u32,
}
#[derive(Debug, Clone)]
pub struct Agent {
pub name: String,
pub config: AgentConfig,
pub system_prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSettings {
pub provider: String,
pub model: String,
pub url: String,
#[serde(alias = "apiKey", default)]
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSettings {
pub thinking: ModelSettings,
pub vision: ModelSettings,
#[serde(rename = "imageGen")]
pub image_gen: ModelSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub role: String, pub content: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Escalation {
pub id: String,
pub category: String, pub level: u8, pub message: String,
pub first_seen: String,
pub last_prompted: String,
pub dismissed: bool,
pub acted_on: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thought {
pub id: String,
pub timestamp: String,
pub message: String,
pub category: String,
pub actions: Vec<ThoughtAction>,
pub dismissed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThoughtAction {
pub label: String,
pub action: String, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupervisorAction {
pub action: String, #[serde(default)]
pub delegate_to: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub files: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
struct LlmRequest {
model: String,
messages: Vec<LlmMessage>,
max_tokens: u32,
temperature: f32,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<LlmOptions>,
}
#[derive(Debug, Serialize)]
struct LlmOptions {
num_ctx: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LlmMessage {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct LlmResponse {
choices: Vec<LlmChoice>,
}
#[derive(Debug, Deserialize)]
struct LlmChoice {
message: LlmMessage,
}
const DEFAULT_AGENTS: &[(&str, &str, &str)] = &[
("supervisor", r#"{"model":"thinking","temperature":0.3,"max_tokens":2048,"tools":["list_routes","list_tables","project_info","file_list"],"max_iterations":1}"#,
r#"You are Tina4, the AI coding assistant built into the Tina4 dev admin.
You are the supervisor. The developer chats with you directly. You understand their request, gather requirements, coordinate specialist agents, and steer the project from start to finish.
## Your Personality
You are direct, practical, and efficient. You ask only what matters. You never explain framework internals or list modules. You talk like a colleague who just gets things done.
## Communication Style
- Ask SHORT questions about what the USER needs, not technology choices
- Never list framework features or module names
- Focus on WHAT the user wants, not HOW you'll build it
- When executing a plan, give clear progress updates: "Step 2 of 5 done. Moving to the login page..."
- After completing work, summarize what was built in plain English
## CRITICAL: Gather Requirements First
When a developer says they want to build something, DO NOT immediately create a plan. Instead:
1. Ask clarifying questions to understand what they need
2. Keep asking until you have enough detail OR the developer says "just build it", "go ahead", "you decide"
## When to Stop Asking
Stop asking and act when:
- The developer says "go ahead", "build it", "just do it", "you decide"
- You have enough detail after 2-3 rounds of questions
- The request is simple enough (e.g. "add a health check endpoint")
## Steering the Project
You keep the big picture in mind:
- Remember what has been built so far in this conversation
- When executing a plan, work through it step by step — one task at a time
- After each task, briefly confirm what was done and what's next
- If something fails, handle it before moving on
- At the end of the plan, give a summary of everything that was built
## Rules
1. Gather requirements before planning
2. Always plan before coding — create plans in .tina4/plans/
3. Never reinvent what the framework provides
4. Keep questions concise — max 3-4 per round
5. If the developer provides a detailed spec upfront, skip questions and plan directly
6. NEVER show file paths, code, or technical jargon to the user
## Actions
Only respond with JSON when ready to delegate:
{"action": "plan", "delegate_to": "planner", "context": "detailed description with all gathered requirements"}
{"action": "code", "delegate_to": "coder", "context": "what to write", "files": ["path1", "path2"]}
{"action": "execute_plan", "delegate_to": "coder", "context": "plan file path to execute step by step"}
{"action": "analyze_image", "delegate_to": "vision"}
{"action": "generate_image", "delegate_to": "image-gen", "prompt": "what to generate"}
{"action": "debug", "delegate_to": "debug", "error": "the error message"}
{"action": "respond", "message": "your conversational response or questions"}
For questions and conversation, ALWAYS use:
{"action": "respond", "message": "your message here"}
"#),
("planner", r#"{"model":"thinking","temperature":0.2,"max_tokens":4096,"tools":["file_read","file_list","list_routes","list_tables"],"max_iterations":3}"#,
r#"You are the Planner agent. You create simple plans that a non-technical person can understand.
## How to write a plan
Write a short numbered list of what will be built. Use plain English. No technical jargon.
Example:
1. Set up the database for storing contacts
2. Create a page where visitors fill in their name, email, and message
3. Save the submission to the database
4. Send an email notification to the site owner
5. Show a thank you message after submission
## RULES — follow these exactly
- NEVER mention file paths, file names, or directories
- NEVER mention code, classes, functions, methods, or APIs
- NEVER use tables or technical formatting
- NEVER say "Create migration", "Create ORM model", "Create route" — say what it DOES, not what it IS
- NEVER mention the framework by name
- NEVER say "ORM", "AutoCrud", "middleware", "endpoint", "schema", "migration"
- Write like you're explaining to someone who doesn't code
- Maximum 10 steps
- Each step is ONE simple sentence
- Start with an objective sentence before the numbered list
"#),
("coder", r#"{"model":"thinking","temperature":0.1,"max_tokens":4096,"tools":["file_read","file_write"],"max_iterations":10}"#,
r#"You are the Coder agent for Tina4 projects. Write code that follows the plan exactly.
## CRITICAL: File Structure
All Tina4 projects use this structure — NEVER use Laravel, Django, Rails, or Express patterns:
```
project/
app.py
migrations/ ← SQL migration files (at project ROOT)
src/
routes/ ← route files (one per file)
orm/ ← ORM model files (one per file)
templates/ ← Frond HTML templates (.twig)
seeds/ ← database seed files
```
NEVER create: app/, Controllers/, Models/, Views/, Database/, database/ folders.
## Python Route Example (src/routes/contact.py)
```python
from tina4_python import get, post
from tina4_python.core import response
@get("/contact")
async def get_contact(request, response):
return response.html(template("contact.twig"))
@post("/contact")
async def post_contact(request, response):
name = request.body.get("name", "")
email = request.body.get("email", "")
message = request.body.get("message", "")
# save to database, send email, etc.
return response.redirect("/contact?success=1")
```
## Python ORM Example (src/orm/Contact.py)
```python
from tina4_python.orm import fields, model
class Contact(model.Model):
__table_name__ = "contacts"
id = fields.AutoField(primary_key=True)
name = fields.CharField(max_length=255)
email = fields.CharField(max_length=255)
message = fields.TextField()
created_at = fields.DateTimeField(auto_now_add=True)
```
## Migration Example (migrations/001_create_contacts.sql) ← at project ROOT
```sql
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255),
email VARCHAR(255),
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## Template Example (src/templates/contact.twig)
```html
<form method="post" action="/contact">
<input name="name" placeholder="Name" required>
<input name="email" type="email" placeholder="Email" required>
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send</button>
</form>
```
## Rules
- ALWAYS use the src/ structure shown above
- NEVER create app/, Controllers/, Models/, Views/, Database/ folders
- One route per file, one model per file
- Return each file as: ## FILE: path/to/file
"#),
("vision", r#"{"model":"vision","temperature":0.3,"max_tokens":2048,"tools":[],"max_iterations":1}"#,
r#"You are the Vision agent for Tina4 projects.
Your job: analyze images (screenshots, mockups, diagrams) and describe what you see in detail.
Describe:
- UI elements (buttons, forms, tables, navigation)
- Layout and structure
- Colors and styling
- Text content
- Suggested Tina4 implementation approach
"#),
("image-gen", r#"{"model":"image-gen","temperature":0.7,"max_tokens":256,"tools":[],"max_iterations":1}"#,
r#"Generate images based on user descriptions."#),
("debug", r#"{"model":"thinking","temperature":0.2,"max_tokens":4096,"tools":["file_read","database_query"],"max_iterations":5}"#,
r#"You are the Debug agent for Tina4 projects.
Your job: analyze errors, read the relevant source files, and suggest fixes.
## Process
1. Parse the error type and traceback
2. Read the file where the error occurred
3. Identify the root cause
4. Suggest a specific fix with code
5. If the fix requires file changes, describe them precisely
"#),
];
pub fn scaffold_agents(project_dir: &Path) {
let agents_dir = project_dir.join(".tina4").join("agents");
for (name, config_json, system_prompt) in DEFAULT_AGENTS {
let agent_dir = agents_dir.join(name);
let config_path = agent_dir.join("config.json");
let prompt_path = agent_dir.join("system.md");
if config_path.exists() && prompt_path.exists() {
continue; }
if let Err(e) = fs::create_dir_all(&agent_dir) {
eprintln!(" {} Failed to create {}: {}", icon_warn(), agent_dir.display(), e);
continue;
}
if !config_path.exists() {
if let Err(e) = fs::write(&config_path, config_json) {
eprintln!(" {} Failed to write {}: {}", icon_warn(), config_path.display(), e);
}
}
if !prompt_path.exists() {
if let Err(e) = fs::write(&prompt_path, system_prompt) {
eprintln!(" {} Failed to write {}: {}", icon_warn(), prompt_path.display(), e);
}
}
}
let _ = fs::create_dir_all(project_dir.join(".tina4").join("plans"));
let _ = fs::create_dir_all(project_dir.join(".tina4").join("chat").join("threads"));
println!(" {} Agent configs scaffolded in .tina4/agents/", icon_ok());
}
pub fn load_agents(project_dir: &Path) -> Vec<Agent> {
let agents_dir = project_dir.join(".tina4").join("agents");
let mut agents = Vec::new();
if !agents_dir.exists() {
return agents;
}
if let Ok(entries) = fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let config_path = path.join("config.json");
let prompt_path = path.join("system.md");
let config: AgentConfig = match fs::read_to_string(&config_path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(c) => c,
Err(e) => {
eprintln!(" {} Bad config for agent '{}': {}", icon_warn(), name, e);
continue;
}
},
Err(_) => continue,
};
let system_prompt = fs::read_to_string(&prompt_path).unwrap_or_default();
agents.push(Agent { name, config, system_prompt });
}
}
agents
}
pub fn load_chat_settings(project_dir: &Path) -> ChatSettings {
let path = project_dir.join(".tina4").join("chat").join("settings.json");
if let Ok(s) = fs::read_to_string(&path) {
if let Ok(settings) = serde_json::from_str(&s) {
return settings;
}
}
ChatSettings {
thinking: ModelSettings {
provider: "tina4".into(),
model: String::new(),
url: "http://41.71.84.173:11437".into(),
api_key: String::new(),
},
vision: ModelSettings {
provider: "tina4".into(),
model: String::new(),
url: "http://41.71.84.173:11434".into(),
api_key: String::new(),
},
image_gen: ModelSettings {
provider: "tina4".into(),
model: String::new(),
url: "http://41.71.84.173:11436".into(),
api_key: String::new(),
},
}
}
pub fn save_message(project_dir: &Path, message: &ChatMessage) {
let history_path = project_dir.join(".tina4").join("chat").join("history.json");
let mut messages: Vec<ChatMessage> = if let Ok(s) = fs::read_to_string(&history_path) {
serde_json::from_str(&s).unwrap_or_default()
} else {
Vec::new()
};
messages.push(message.clone());
let _ = fs::write(&history_path, serde_json::to_string_pretty(&messages).unwrap_or_default());
}
pub fn load_history(project_dir: &Path) -> Vec<ChatMessage> {
let path = project_dir.join(".tina4").join("chat").join("history.json");
if let Ok(s) = fs::read_to_string(&path) {
serde_json::from_str(&s).unwrap_or_default()
} else {
Vec::new()
}
}
async fn fetch_first_model(base_url: &str) -> Option<String> {
let client = reqwest::Client::new();
if let Ok(resp) = client.get(format!("{}/api/tags", base_url)).send().await {
if let Ok(text) = resp.text().await {
if let Ok(data) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(models) = data["models"].as_array() {
if let Some(first) = models.first() {
let name = first["name"].as_str()
.or_else(|| first["model"].as_str())
.unwrap_or("");
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
}
if let Ok(resp) = client.get(format!("{}/v1/models", base_url)).send().await {
if let Ok(text) = resp.text().await {
if let Ok(data) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(models) = data["data"].as_array() {
if let Some(first) = models.first() {
if let Some(id) = first["id"].as_str() {
return Some(id.to_string());
}
}
}
}
}
}
None
}
pub async fn llm_call(
settings: &ModelSettings,
system_prompt: &str,
messages: &[LlmMessage],
max_tokens: u32,
temperature: f32,
) -> Result<String, String> {
let client = reqwest::Client::new();
let model_name = if settings.model.is_empty() {
let base = settings.url.trim_end_matches('/');
match fetch_first_model(base).await {
Some(m) => m,
None => return Err("No models available on the server. Check the URL.".into()),
}
} else {
settings.model.clone()
};
let mut all_messages = Vec::new();
if !system_prompt.is_empty() {
all_messages.push(LlmMessage {
role: "system".into(),
content: system_prompt.into(),
});
}
all_messages.extend_from_slice(messages);
let options = if settings.provider == "custom" || settings.provider == "tina4" {
Some(LlmOptions { num_ctx: 32768 })
} else {
None
};
let body = LlmRequest {
model: model_name,
messages: all_messages,
max_tokens,
temperature,
stream: None,
options,
};
let base_url = settings.url.trim_end_matches('/');
let api_url = match settings.provider.as_str() {
"anthropic" => format!("{}/v1/messages", base_url),
"openai" => format!("{}/v1/chat/completions", base_url),
"tina4" => format!("{}/v1/chat/completions", base_url),
_ => {
if base_url.contains("/v1/") || base_url.contains("/api/") {
base_url.to_string()
} else {
format!("{}/v1/chat/completions", base_url)
}
}
};
let mut req = client.post(&api_url)
.header("Content-Type", "application/json")
.json(&body);
if !settings.api_key.is_empty() {
if settings.provider == "anthropic" {
req = req.header("x-api-key", &settings.api_key)
.header("anthropic-version", "2023-06-01");
} else {
req = req.header("Authorization", format!("Bearer {}", settings.api_key));
}
}
let resp = req.send().await.map_err(|e| format!("Request failed: {}", e))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| format!("Read failed: {}", e))?;
if !status.is_success() {
return Err(format!("LLM API error {}: {}", status, &text[..text.len().min(200)]));
}
let parsed: LlmResponse = serde_json::from_str(&text)
.map_err(|e| format!("Parse failed: {} — body: {}", e, &text[..text.len().min(200)]))?;
parsed.choices.first()
.map(|c| c.message.content.clone())
.ok_or_else(|| "No response content".into())
}
pub fn parse_supervisor_action(response: &str) -> Option<SupervisorAction> {
let trimmed = response.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).ok();
}
if let Some(start) = trimmed.find("```json") {
let json_start = start + 7;
if let Some(end) = trimmed[json_start..].find("```") {
let json_str = trimmed[json_start..json_start + end].trim();
return serde_json::from_str(json_str).ok();
}
}
if let Some(start) = trimmed.find('{') {
if let Some(end) = trimmed.rfind('}') {
let json_str = &trimmed[start..=end];
return serde_json::from_str(json_str).ok();
}
}
Some(SupervisorAction {
action: "respond".into(),
message: Some(response.to_string()),
delegate_to: None,
context: None,
files: None,
prompt: None,
error: None,
})
}
pub fn load_escalations(project_dir: &Path) -> Vec<Escalation> {
let path = project_dir.join(".tina4").join("chat").join("escalations.json");
if let Ok(s) = fs::read_to_string(&path) {
serde_json::from_str(&s).unwrap_or_default()
} else {
Vec::new()
}
}
pub fn save_escalations(project_dir: &Path, escalations: &[Escalation]) {
let path = project_dir.join(".tina4").join("chat").join("escalations.json");
let _ = fs::write(&path, serde_json::to_string_pretty(escalations).unwrap_or_default());
}
pub fn load_thoughts(project_dir: &Path) -> Vec<Thought> {
let path = project_dir.join(".tina4").join("chat").join("thoughts.json");
if let Ok(s) = fs::read_to_string(&path) {
serde_json::from_str(&s).unwrap_or_default()
} else {
Vec::new()
}
}
pub fn save_thought(project_dir: &Path, thought: &Thought) {
let path = project_dir.join(".tina4").join("chat").join("thoughts.json");
let mut thoughts = load_thoughts(project_dir);
thoughts.push(thought.clone());
if thoughts.len() > 50 {
thoughts = thoughts[thoughts.len() - 50..].to_vec();
}
let _ = fs::write(&path, serde_json::to_string_pretty(&thoughts).unwrap_or_default());
}
pub fn build_project_context(project_dir: &Path) -> String {
let mut ctx = String::new();
let lang = if project_dir.join("app.py").exists() { "python" }
else if project_dir.join("index.php").exists() || project_dir.join("composer.json").exists() { "php" }
else if project_dir.join("app.rb").exists() || project_dir.join("Gemfile").exists() { "ruby" }
else if project_dir.join("app.ts").exists() || project_dir.join("package.json").exists() { "nodejs" }
else { "python" };
ctx.push_str(&format!("Language: {}\n", lang));
ctx.push_str(&format!("Project root: {}\n\n", project_dir.display()));
let routes_dir = project_dir.join("src").join("routes");
if routes_dir.exists() {
ctx.push_str("## Existing route files:\n");
if let Ok(entries) = fs::read_dir(&routes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
ctx.push_str(&format!("- src/routes/{}", name));
if let Ok(content) = fs::read_to_string(&path) {
let preview: String = content.lines().take(5).collect::<Vec<_>>().join("\n");
ctx.push_str(&format!("\n```\n{}\n```\n", preview));
} else {
ctx.push('\n');
}
}
}
}
ctx.push('\n');
}
let orm_dir = project_dir.join("src").join("orm");
if orm_dir.exists() {
ctx.push_str("## Existing ORM models:\n");
if let Ok(entries) = fs::read_dir(&orm_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
ctx.push_str(&format!("- src/orm/{}", name));
if let Ok(content) = fs::read_to_string(&path) {
let preview: String = content.lines().take(10).collect::<Vec<_>>().join("\n");
ctx.push_str(&format!("\n```\n{}\n```\n", preview));
} else {
ctx.push('\n');
}
}
}
}
ctx.push('\n');
}
let tmpl_dir = project_dir.join("src").join("templates");
if tmpl_dir.exists() {
ctx.push_str("## Existing templates:\n");
if let Ok(entries) = fs::read_dir(&tmpl_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
ctx.push_str(&format!("- src/templates/{}\n", name));
}
}
}
ctx.push('\n');
}
let mig_dir = project_dir.join("migrations");
if mig_dir.exists() {
ctx.push_str("## Existing migrations (at project root):\n");
if let Ok(entries) = fs::read_dir(&mig_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
ctx.push_str(&format!("- migrations/{}\n", name));
}
}
ctx.push('\n');
}
let app_file = match lang {
"python" => "app.py",
"php" => "index.php",
"ruby" => "app.rb",
_ => "app.ts",
};
if let Ok(content) = fs::read_to_string(project_dir.join(app_file)) {
ctx.push_str(&format!("## {} (entry point):\n```\n{}\n```\n\n", app_file, content));
}
if let Ok(content) = fs::read_to_string(project_dir.join(".env")) {
let safe: String = content.lines()
.map(|line| {
if let Some(pos) = line.find('=') {
format!("{}=***", &line[..pos])
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
ctx.push_str(&format!("## .env keys:\n{}\n\n", safe));
}
ctx
}
pub fn scan_project(project_dir: &Path) -> Vec<(String, String, String)> {
let mut issues = Vec::new();
if let Ok(output) = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_dir)
.output()
{
let status = String::from_utf8_lossy(&output.stdout);
let changed_files: Vec<&str> = status.lines().collect();
if changed_files.len() > 3 {
issues.push((
"uncommitted".into(),
"uncommitted_files".into(),
format!("{} uncommitted files in the project", changed_files.len()),
));
}
}
let routes_dir = project_dir.join("src").join("routes");
let tests_dir_a = project_dir.join("tests");
let tests_dir_b = project_dir.join("spec");
if routes_dir.exists() {
let route_count = fs::read_dir(&routes_dir)
.map(|entries| entries.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "py" || ext == "php" || ext == "rb" || ext == "ts"))
.count())
.unwrap_or(0);
let test_count = [&tests_dir_a, &tests_dir_b].iter()
.filter_map(|d| fs::read_dir(d).ok())
.flat_map(|entries| entries.filter_map(|e| e.ok()))
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("test_") || name.ends_with("_test.") || name.ends_with("_spec.")
})
.count();
if route_count > 0 && test_count == 0 {
issues.push((
"untested".into(),
"no_tests".into(),
format!("{} routes with no test files at all", route_count),
));
} else if route_count > test_count + 2 {
issues.push((
"untested".into(),
"low_coverage".into(),
format!("{} routes but only {} test files", route_count, test_count),
));
}
}
if project_dir.join(".env").exists() && !project_dir.join(".env.example").exists() {
issues.push((
"convention".into(),
"no_env_example".into(),
"Project has .env but no .env.example — other developers won't know what vars are needed".into(),
));
}
issues
}
pub async fn background_thinking_loop(
project_dir: PathBuf,
settings: ChatSettings,
thought_tx: tokio::sync::broadcast::Sender<String>,
) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); interval.tick().await;
loop {
interval.tick().await;
let issues = scan_project(&project_dir);
if issues.is_empty() {
continue;
}
let mut escalations = load_escalations(&project_dir);
let now = chrono_now();
for (category, id, description) in &issues {
let existing = escalations.iter_mut().find(|e| e.id == *id);
if let Some(esc) = existing {
if esc.dismissed || esc.acted_on { continue; }
if esc.level < 3 {
esc.level += 1;
esc.last_prompted = now.clone();
esc.message = description.clone();
}
} else {
escalations.push(Escalation {
id: id.clone(), category: category.clone(), level: 1,
message: description.clone(), first_seen: now.clone(),
last_prompted: now.clone(), dismissed: false, acted_on: false,
});
}
}
save_escalations(&project_dir, &escalations);
let active: Vec<&Escalation> = escalations.iter()
.filter(|e| !e.dismissed && !e.acted_on && e.level >= 1)
.collect();
if let Some(top) = active.first() {
let reflection_prompt = format!(
"You noticed this about the developer's project: {}\n\
Escalation level: {} (1=gentle, 2=concerned, 3=urgent)\n\
Category: {}\n\n\
Write a single short message (2-3 sentences max) as if you're a friendly senior developer \
who genuinely cares about the project. Be conversational, not robotic. \
Show you understand WHY this matters, not just WHAT the issue is. \
If level 3, express real concern about risk. \
Don't use bullet points. Don't use headers. Just talk naturally.",
top.message, top.level, top.category
);
let human_message = match llm_call(
&settings.thinking, "",
&[LlmMessage { role: "user".into(), content: reflection_prompt }],
256, 0.7
).await {
Ok(msg) => {
let cleaned = msg.trim().trim_matches('"').to_string();
cleaned
}
Err(_) => top.message.clone(), };
let actions = match top.category.as_str() {
"uncommitted" if top.level >= 3 => vec![
ThoughtAction { label: "Create backup branch".into(), action: "create_branch".into() },
ThoughtAction { label: "Not now".into(), action: "dismiss".into() },
],
"uncommitted" => vec![
ThoughtAction { label: "Let's commit".into(), action: "commit".into() },
ThoughtAction { label: "I'm on it".into(), action: "dismiss".into() },
],
"untested" if top.level >= 2 => vec![
ThoughtAction { label: "Help me write tests".into(), action: "scaffold_tests".into() },
ThoughtAction { label: "I'll handle it".into(), action: "dismiss".into() },
],
"untested" => vec![
ThoughtAction { label: "Good idea, draft some".into(), action: "draft_tests".into() },
ThoughtAction { label: "Later".into(), action: "dismiss".into() },
],
_ => vec![
ThoughtAction { label: "Tell me more".into(), action: "act".into() },
ThoughtAction { label: "Got it".into(), action: "dismiss".into() },
],
};
let thought = Thought {
id: format!("{:x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
timestamp: now.clone(),
message: human_message,
category: top.category.clone(),
actions,
dismissed: false,
};
save_thought(&project_dir, &thought);
let thought_json = serde_json::to_string(&thought).unwrap_or_default();
let _ = thought_tx.send(format!("event: thought\ndata: {}\n\n", thought_json));
}
}
}
pub fn run(port: u16) {
println!(" {} Starting agent server on port {}", icon_play(), port);
let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if !project_dir.join(".tina4").join("agents").exists() {
scaffold_agents(&project_dir);
}
let agents = load_agents(&project_dir);
println!(" {} Loaded {} agents: {}", icon_info(),
agents.len(),
agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", "));
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(async move {
let settings = load_chat_settings(&project_dir);
let (thought_tx, _) = tokio::sync::broadcast::channel::<String>(32);
let bg_dir = project_dir.clone();
let bg_settings = settings.clone();
let bg_tx = thought_tx.clone();
tokio::spawn(async move {
background_thinking_loop(bg_dir, bg_settings, bg_tx).await;
});
println!(" {} Background thinking loop started (every 5 min)", icon_info());
serve_agent_http(port, &project_dir, &agents, thought_tx).await;
});
}
async fn serve_agent_http(port: u16, project_dir: &Path, agents: &[Agent], thought_tx: tokio::sync::broadcast::Sender<String>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener as AsyncTcpListener;
let listener = AsyncTcpListener::bind(format!("127.0.0.1:{}", port))
.await
.expect("Failed to bind agent port");
println!(" {} Agent server listening on http://127.0.0.1:{}", icon_ok(), port);
loop {
let (mut stream, _addr) = match listener.accept().await {
Ok(s) => s,
Err(_) => continue,
};
let project_dir = project_dir.to_path_buf();
let agents = agents.to_vec();
tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let n = match stream.read(&mut buf).await {
Ok(n) if n > 0 => n,
_ => return,
};
let request = String::from_utf8_lossy(&buf[..n]);
let first_line = request.lines().next().unwrap_or("");
if first_line.starts_with("GET /health") {
let body = r#"{"status":"ok"}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
} else if first_line.starts_with("GET /agents") {
let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
let body = serde_json::to_string(&names).unwrap_or_default();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
} else if first_line.starts_with("GET /history") {
let history = load_history(&project_dir);
let body = serde_json::to_string(&history).unwrap_or_default();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
} else if first_line.starts_with("GET /thoughts") {
let thoughts = load_thoughts(&project_dir);
let body = serde_json::to_string(&thoughts).unwrap_or_default();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
} else if first_line.starts_with("POST /thoughts/dismiss") {
let body_start = request.find("\r\n\r\n").unwrap_or(n) + 4;
let body_str = &request[body_start..];
#[derive(Deserialize)]
struct DismissReq { id: String }
if let Ok(req) = serde_json::from_str::<DismissReq>(body_str) {
let mut thoughts = load_thoughts(&project_dir);
if let Some(t) = thoughts.iter_mut().find(|t| t.id == req.id) {
t.dismissed = true;
}
let path = project_dir.join(".tina4").join("chat").join("thoughts.json");
let _ = fs::write(&path, serde_json::to_string_pretty(&thoughts).unwrap_or_default());
let mut escalations = load_escalations(&project_dir);
if let Some(e) = escalations.iter_mut().find(|e| !e.dismissed) {
e.dismissed = true;
}
save_escalations(&project_dir, &escalations);
}
let body = r#"{"ok":true}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
} else if first_line.starts_with("POST /chat") {
let body_start = request.find("\r\n\r\n").unwrap_or(n) + 4;
let body_str = &request[body_start..];
#[derive(Deserialize)]
struct ChatRequest {
message: String,
#[serde(default)]
thread_id: Option<String>,
#[serde(default)]
settings: Option<ChatSettings>,
}
let chat_req: ChatRequest = match serde_json::from_str(body_str) {
Ok(r) => r,
Err(e) => {
let err_body = format!(r#"{{"error":"Invalid request: {}"}}"#, e);
let resp = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
err_body.len(), err_body
);
let _ = stream.write_all(resp.as_bytes()).await;
return;
}
};
let settings = chat_req.settings.unwrap_or_else(|| load_chat_settings(&project_dir));
let supervisor = agents.iter().find(|a| a.name == "supervisor");
let model_settings = &settings.thinking;
let user_msg = ChatMessage {
id: format!("{:x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "user".into(),
content: chat_req.message.clone(),
timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(),
agent: None,
};
save_message(&project_dir, &user_msg);
let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\nX-Accel-Buffering: no\r\n\r\n";
let _ = stream.write_all(headers.as_bytes()).await;
let _ = stream.write_all(
format!("event: status\ndata: {{\"text\":\"Analyzing request...\",\"agent\":\"supervisor\"}}\n\n").as_bytes()
).await;
let _ = stream.flush().await;
async fn sse_event(stream: &mut tokio::net::TcpStream, event: &str, data: &str) {
use tokio::io::AsyncWriteExt;
let _ = stream.write_all(format!("event: {}\ndata: {}\n\n", event, data).as_bytes()).await;
let _ = stream.flush().await;
}
fn sse_json(obj: &serde_json::Value) -> String {
serde_json::to_string(obj).unwrap_or_default()
}
fn resolve_model<'a>(agent_name: &str, agents: &[Agent], settings: &'a ChatSettings) -> &'a ModelSettings {
let model_type = agents.iter()
.find(|a| a.name == agent_name)
.map(|a| a.config.model.as_str())
.unwrap_or("thinking");
match model_type {
"vision" => &settings.vision,
"image-gen" => &settings.image_gen,
_ => &settings.thinking,
}
}
let supervisor_prompt = supervisor.map(|s| s.system_prompt.as_str()).unwrap_or("");
let history = load_history(&project_dir);
let recent: Vec<&ChatMessage> = history.iter()
.filter(|m| m.thread_id == chat_req.thread_id)
.rev().take(20).collect::<Vec<_>>().into_iter().rev().collect();
let mut msgs: Vec<LlmMessage> = Vec::new();
let plans_dir = project_dir.join(".tina4").join("plans");
let latest_plan = if plans_dir.exists() {
fs::read_dir(&plans_dir).ok()
.and_then(|entries| entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
.max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok())))
.and_then(|entry| fs::read_to_string(entry.path()).ok())
} else {
None
};
if let Some(ref plan) = latest_plan {
let plan_summary = if plan.len() > 800 { format!("{}...", &plan[..800]) } else { plan.clone() };
msgs.push(LlmMessage {
role: "system".into(),
content: format!("Current project plan:\n{}", plan_summary),
});
}
for m in &recent {
let mut content = m.content.clone();
if content.len() > 600 {
content = format!("{}...(truncated)", &content[..600]);
}
msgs.push(LlmMessage {
role: if m.role == "user" { "user".into() } else { "assistant".into() },
content,
});
}
msgs.push(LlmMessage { role: "user".into(), content: chat_req.message.clone() });
let supervisor_reply = match llm_call(model_settings, supervisor_prompt, &msgs, 2048, 0.3).await {
Ok(r) => r,
Err(e) => {
let escaped = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "error", &format!("{{\"message\":\"{}\"}}", escaped)).await;
return;
}
};
let action = parse_supervisor_action(&supervisor_reply);
match action {
Some(SupervisorAction { action: ref a, .. }) if a == "plan" => {
let ctx = action.as_ref().and_then(|a| a.context.clone()).unwrap_or_default();
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "→ Planner: creating plan...", "agent": "planner"}))).await;
let planner = agents.iter().find(|a| a.name == "planner");
let planner_prompt = planner.map(|p| p.system_prompt.as_str()).unwrap_or("");
let planner_model = resolve_model("planner", &agents, &settings);
let planner_msg = format!(
"Create an implementation plan for the following request:\n\n{}",
ctx
);
let planner_msgs = vec![LlmMessage { role: "user".into(), content: planner_msg }];
match llm_call(planner_model, planner_prompt, &planner_msgs, 4096, 0.2).await {
Ok(plan_content) => {
let plan_name = format!("{}-plan.md", chrono_now().replace("Z", ""));
let plan_path = project_dir.join(".tina4").join("plans").join(&plan_name);
let _ = fs::write(&plan_path, &plan_content);
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({
"text": format!("Plan created: .tina4/plans/{}", plan_name),
"agent": "planner"
}))).await;
let plan_escaped = plan_content.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "plan", &format!(
"{{\"content\":\"{}\",\"agent\":\"planner\",\"file\":\".tina4/plans/{}\",\"approve\":true}}",
plan_escaped, plan_name
)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(),
content: plan_content,
timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(),
agent: Some("planner".into()),
});
}
Err(e) => {
let escaped = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "error", &format!("{{\"message\":\"Planner failed: {}\"}}", escaped)).await;
}
}
}
Some(SupervisorAction { action: ref a, .. }) if a == "code" => {
let ctx = action.as_ref().and_then(|a| a.context.clone()).unwrap_or_default();
let files = action.as_ref().and_then(|a| a.files.clone()).unwrap_or_default();
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "→ Coder: writing code...", "agent": "coder"}))).await;
let coder = agents.iter().find(|a| a.name == "coder");
let coder_prompt = coder.map(|c| c.system_prompt.as_str()).unwrap_or("");
let coder_model = resolve_model("coder", &agents, &settings);
let coder_msg = format!(
"Write the following code:\n\n{}\n\nFiles to create/modify: {:?}\n\nReturn each file as:\n## FILE: path/to/file\n```\ncontent\n```",
ctx, files
);
let coder_msgs = vec![LlmMessage { role: "user".into(), content: coder_msg }];
match llm_call(coder_model, coder_prompt, &coder_msgs, 4096, 0.1).await {
Ok(code_output) => {
let mut files_written = Vec::new();
for section in code_output.split("## FILE:") {
let section = section.trim();
if section.is_empty() { continue; }
let mut lines = section.lines();
if let Some(file_path) = lines.next() {
let file_path = file_path.trim();
let remaining: String = lines.collect::<Vec<&str>>().join("\n");
let content = if let Some(start) = remaining.find("```") {
let after = &remaining[start + 3..];
let after = if let Some(nl) = after.find('\n') { &after[nl+1..] } else { after };
if let Some(end) = after.find("```") { &after[..end] } else { after }
} else {
remaining.as_str()
};
let full_path = project_dir.join(file_path);
if let Some(parent) = full_path.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::write(&full_path, content.trim()).is_ok() {
files_written.push(file_path.to_string());
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({
"text": format!("Written: {}", file_path),
"agent": "coder"
}))).await;
}
}
}
let msg = if files_written.is_empty() {
code_output.clone()
} else {
format!("Created {} files:\n{}", files_written.len(), files_written.iter().map(|f| format!("- {}", f)).collect::<Vec<_>>().join("\n"))
};
let escaped = msg.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "message", &format!(
"{{\"content\":\"{}\",\"agent\":\"coder\",\"files_changed\":{}}}", escaped,
serde_json::to_string(&files_written).unwrap_or_default()
)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(),
content: msg,
timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(),
agent: Some("coder".into()),
});
}
Err(e) => {
let escaped = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "error", &format!("{{\"message\":\"Coder failed: {}\"}}", escaped)).await;
}
}
}
Some(SupervisorAction { action: ref a, .. }) if a == "execute_plan" => {
let plan_file = action.as_ref().and_then(|a| a.context.clone()).unwrap_or_default();
let plan_path = project_dir.join(&plan_file);
let plan_content = fs::read_to_string(&plan_path).unwrap_or_default();
if plan_content.is_empty() {
sse_event(&mut stream, "message", &format!(
"{{\"content\":\"I couldn't find the plan. Let me create a new one.\",\"agent\":\"supervisor\"}}"
)).await;
} else {
let steps: Vec<String> = plan_content.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.len() > 2 && trimmed.chars().next().map_or(false, |c| c.is_ascii_digit())
&& (trimmed.contains(". ") || trimmed.contains(") "))
})
.map(|line| {
let trimmed = line.trim();
if let Some(pos) = trimmed.find(". ") {
trimmed[pos + 2..].to_string()
} else if let Some(pos) = trimmed.find(") ") {
trimmed[pos + 2..].to_string()
} else {
trimmed.to_string()
}
})
.collect();
let total_steps = steps.len();
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({
"text": format!("Executing plan — {} steps", total_steps),
"agent": "supervisor"
}))).await;
let coder = agents.iter().find(|a| a.name == "coder");
let coder_prompt = coder.map(|c| c.system_prompt.as_str()).unwrap_or("");
let coder_model = resolve_model("coder", &agents, &settings);
let mut all_files_written: Vec<String> = Vec::new();
let mut step_summaries: Vec<String> = Vec::new();
for (i, step) in steps.iter().enumerate() {
let step_num = i + 1;
let progress_msg = format!("Step {} of {}: {}", step_num, total_steps, step);
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({
"text": progress_msg.clone(),
"agent": "coder"
}))).await;
sse_event(&mut stream, "message", &format!(
"{{\"content\":\"**Step {} of {}:** {}\\n\\nWorking on this now...\",\"agent\":\"supervisor\"}}",
step_num, total_steps, step.replace('\\', "\\\\").replace('"', "\\\"")
)).await;
let coder_msg = format!(
"Implement this single step from the project plan:\n\n**Step {}:** {}\n\n\
Full plan context:\n{}\n\n\
Project directory: {}\n\n\
Return each file as:\n## FILE: path/to/file\n```\ncontent\n```",
step_num, step, plan_content, project_dir.display()
);
let coder_msgs = vec![LlmMessage { role: "user".into(), content: coder_msg }];
match llm_call(coder_model, coder_prompt, &coder_msgs, 4096, 0.1).await {
Ok(code_output) => {
let mut step_files = Vec::new();
for section in code_output.split("## FILE:") {
let section = section.trim();
if section.is_empty() { continue; }
let mut lines = section.lines();
if let Some(file_path) = lines.next() {
let file_path = file_path.trim();
let remaining: String = lines.collect::<Vec<&str>>().join("\n");
let content = if let Some(start) = remaining.find("```") {
let after = &remaining[start + 3..];
let after = if let Some(nl) = after.find('\n') { &after[nl+1..] } else { after };
if let Some(end) = after.find("```") { &after[..end] } else { after }
} else {
remaining.as_str()
};
let full_path = project_dir.join(file_path);
if let Some(parent) = full_path.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::write(&full_path, content.trim()).is_ok() {
step_files.push(file_path.to_string());
all_files_written.push(file_path.to_string());
}
}
}
let done_msg = if step_files.is_empty() {
format!("Step {} complete.", step_num)
} else {
format!("Step {} complete — {} files updated.", step_num, step_files.len())
};
step_summaries.push(format!("{}. {} ✓", step_num, step));
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({
"text": done_msg,
"agent": "coder"
}))).await;
}
Err(e) => {
step_summaries.push(format!("{}. {} ✗ (failed)", step_num, step));
let err_escaped = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "message", &format!(
"{{\"content\":\"Step {} had an issue: {}. Moving on...\",\"agent\":\"supervisor\"}}",
step_num, err_escaped
)).await;
}
}
}
let summary = format!(
"All done! Here's what I built:\\n\\n{}\\n\\n{} files were created or updated.",
step_summaries.iter().map(|s| format!("- {}", s.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\\n"),
all_files_written.len()
);
sse_event(&mut stream, "message", &format!(
"{{\"content\":\"{}\",\"agent\":\"supervisor\",\"files_changed\":{}}}",
summary, serde_json::to_string(&all_files_written).unwrap_or_default()
)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(),
content: format!("Plan executed: {} steps, {} files written", step_summaries.len(), all_files_written.len()),
timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(),
agent: Some("supervisor".into()),
});
}
}
Some(SupervisorAction { action: ref a, .. }) if a == "debug" => {
let err_msg = action.as_ref().and_then(|a| a.error.clone()).unwrap_or_default();
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "→ Debug: analyzing error...", "agent": "debug"}))).await;
let debug_agent = agents.iter().find(|a| a.name == "debug");
let debug_prompt = debug_agent.map(|d| d.system_prompt.as_str()).unwrap_or("");
let debug_model = resolve_model("debug", &agents, &settings);
let debug_msgs = vec![LlmMessage { role: "user".into(), content: format!("Analyze this error and suggest a fix:\n\n{}", err_msg) }];
match llm_call(debug_model, debug_prompt, &debug_msgs, 4096, 0.2).await {
Ok(analysis) => {
let escaped = analysis.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"debug\"}}", escaped)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(), content: analysis, timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(), agent: Some("debug".into()),
});
}
Err(e) => {
let escaped = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "error", &format!("{{\"message\":\"Debug failed: {}\"}}", escaped)).await;
}
}
}
Some(SupervisorAction { action: ref a, message: Some(ref msg), .. }) if a == "respond" => {
let escaped = msg.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "Responding...", "agent": "supervisor"}))).await;
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"supervisor\"}}", escaped)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(), content: msg.clone(), timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(), agent: Some("supervisor".into()),
});
}
Some(SupervisorAction { action: ref a, .. }) if a == "generate_image" => {
let img_prompt = action.as_ref().and_then(|a| a.prompt.clone()).unwrap_or_default();
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "→ Image Gen: generating image...", "agent": "image-gen"}))).await;
let img_settings = &settings.image_gen;
let base_url = img_settings.url.trim_end_matches('/');
let img_url = if base_url.contains("/v1/") { base_url.to_string() } else { format!("{}/v1/images/generations", base_url) };
let client = reqwest::Client::new();
let img_body = serde_json::json!({
"model": img_settings.model,
"prompt": img_prompt,
"n": 1,
"size": "512x512"
});
let mut req = client.post(&img_url).header("Content-Type", "application/json").json(&img_body);
if !img_settings.api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {}", img_settings.api_key));
}
match req.send().await {
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(data) => {
let img_data = data["data"][0]["url"].as_str()
.or_else(|| data["data"][0]["b64_json"].as_str())
.unwrap_or("");
let is_b64 = data["data"][0]["b64_json"].is_string();
let img_html = if is_b64 {
format!("Generated image for: {}\\n\\n<img src=\\\"data:image/png;base64,{}\\\" style=\\\"max-width:100%;border-radius:8px\\\">", img_prompt.replace('"', "\\\""), img_data.replace('"', "\\\""))
} else if !img_data.is_empty() {
format!("Generated image for: {}\\n\\n<img src=\\\"{}\\\" style=\\\"max-width:100%;border-radius:8px\\\">", img_prompt.replace('"', "\\\""), img_data.replace('"', "\\\""))
} else {
format!("Image generated for: {}", img_prompt.replace('"', "\\\""))
};
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"image-gen\"}}", img_html)).await;
}
Err(_) => {
let escaped = format!("Image generation returned unexpected response").replace('"', "\\\"");
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"image-gen\"}}", escaped)).await;
}
}
}
Err(e) => {
let escaped = format!("Image generation failed: {}", e).replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "error", &format!("{{\"message\":\"{}\"}}", escaped)).await;
}
}
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(), content: format!("Generated image: {}", img_prompt), timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(), agent: Some("image-gen".into()),
});
}
Some(SupervisorAction { action: ref a, .. }) if a == "analyze_image" => {
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "→ Vision: analyzing image...", "agent": "vision"}))).await;
let msg = "I can see you want me to analyze an image. Please attach an image and I'll describe what I see.";
let escaped = msg.replace('"', "\\\"");
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"vision\"}}", escaped)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(), content: msg.to_string(), timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(), agent: Some("vision".into()),
});
}
_ => {
let display_msg = if let Some(ref act) = action {
act.message.clone()
.or_else(|| act.context.clone())
.or_else(|| act.prompt.clone())
.unwrap_or_else(|| "I'm processing your request...".to_string())
} else {
"I'm processing your request...".to_string()
};
let escaped = display_msg.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_event(&mut stream, "message", &format!("{{\"content\":\"{}\",\"agent\":\"supervisor\"}}", escaped)).await;
save_message(&project_dir, &ChatMessage {
id: format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis()),
role: "assistant".into(), content: display_msg, timestamp: chrono_now(),
thread_id: chat_req.thread_id.clone(), agent: Some("supervisor".into()),
});
}
}
sse_event(&mut stream, "status", &sse_json(&serde_json::json!({"text": "Done", "agent": "supervisor"}))).await;
sse_event(&mut stream, "done", "{}").await;
} else if first_line.starts_with("POST /execute") {
let body_start = request.find("\r\n\r\n").unwrap_or(n) + 4;
let body_str = &request[body_start..];
#[derive(Deserialize)]
struct ExecRequest {
plan_file: String,
#[serde(default)]
settings: Option<ChatSettings>,
#[serde(default)]
resume: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PlanState {
completed: Vec<usize>,
files: Vec<String>,
}
let exec_req: ExecRequest = match serde_json::from_str(body_str) {
Ok(r) => r,
Err(e) => {
let err_body = format!(r#"{{"error":"Invalid request: {}"}}"#, e);
let resp = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n{}",
err_body.len(), err_body
);
let _ = stream.write_all(resp.as_bytes()).await;
return;
}
};
let settings = exec_req.settings.unwrap_or_else(|| load_chat_settings(&project_dir));
let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\nX-Accel-Buffering: no\r\n\r\n";
let _ = stream.write_all(headers.as_bytes()).await;
async fn sse_ev(stream: &mut tokio::net::TcpStream, event: &str, data: &str) {
use tokio::io::AsyncWriteExt;
let _ = stream.write_all(format!("event: {}\ndata: {}\n\n", event, data).as_bytes()).await;
let _ = stream.flush().await;
}
fn sse_j(obj: &serde_json::Value) -> String {
serde_json::to_string(obj).unwrap_or_default()
}
let plan_path = project_dir.join(&exec_req.plan_file);
let plan_content = fs::read_to_string(&plan_path).unwrap_or_default();
if plan_content.is_empty() {
sse_ev(&mut stream, "error", &sse_j(&serde_json::json!({"message":"Plan file not found"}))).await;
sse_ev(&mut stream, "done", "{}").await;
return;
}
let steps: Vec<String> = plan_content.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.len() > 2 && trimmed.chars().next().map_or(false, |c| c.is_ascii_digit())
&& (trimmed.contains(". ") || trimmed.contains(") "))
})
.map(|line| {
let trimmed = line.trim();
if let Some(pos) = trimmed.find(". ") {
trimmed[pos + 2..].to_string()
} else if let Some(pos) = trimmed.find(") ") {
trimmed[pos + 2..].to_string()
} else {
trimmed.to_string()
}
})
.collect();
let total = steps.len();
let state_path = plan_path.with_extension("state.json");
let mut state: PlanState = if exec_req.resume {
fs::read_to_string(&state_path).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
PlanState::default()
};
let skip_count = state.completed.len();
if skip_count > 0 {
sse_ev(&mut stream, "message", &format!(
"{{\"content\":\"Resuming from step {} — {} steps already done.\",\"agent\":\"supervisor\"}}",
skip_count + 1, skip_count
)).await;
}
sse_ev(&mut stream, "status", &sse_j(&serde_json::json!({"text": format!("Building — {} steps ({} remaining)", total, total - skip_count), "agent": "supervisor"}))).await;
let coder = agents.iter().find(|a| a.name == "coder");
let coder_prompt = coder.map(|c| c.system_prompt.as_str()).unwrap_or("");
let coder_model_type = coder.map(|a| a.config.model.as_str()).unwrap_or("thinking");
let coder_model = match coder_model_type { "vision" => &settings.vision, "image-gen" => &settings.image_gen, _ => &settings.thinking };
let mut summaries: Vec<String> = Vec::new();
let mut failed = false;
for (i, step) in steps.iter().enumerate() {
let num = i + 1;
if state.completed.contains(&num) {
summaries.push(format!("{}. {} ✓ (done earlier)", num, step));
continue;
}
let step_escaped = step.replace('\\', "\\\\").replace('"', "\\\"");
sse_ev(&mut stream, "message", &format!(
"{{\"content\":\"**Step {} of {}:** {}\",\"agent\":\"supervisor\"}}",
num, total, step_escaped
)).await;
sse_ev(&mut stream, "status", &sse_j(&serde_json::json!({"text": format!("Step {}/{}: {}", num, total, step), "agent": "coder"}))).await;
let project_ctx = build_project_context(&project_dir);
let coder_msg = format!(
"## Project Context\n{}\n\n\
## Task\nImplement step {} of {}:\n**{}**\n\n\
## Full Plan\n{}\n\n\
Return each file as:\n## FILE: path/to/file\n```\ncontent\n```",
project_ctx, num, total, step, plan_content
);
let coder_msgs = vec![LlmMessage { role: "user".into(), content: coder_msg }];
match llm_call(coder_model, coder_prompt, &coder_msgs, 4096, 0.1).await {
Ok(code_output) => {
let mut step_files = Vec::new();
for section in code_output.split("## FILE:") {
let section = section.trim();
if section.is_empty() { continue; }
let mut lines = section.lines();
if let Some(file_path) = lines.next() {
let file_path = file_path.trim();
let remaining: String = lines.collect::<Vec<&str>>().join("\n");
let content = if let Some(start) = remaining.find("```") {
let after = &remaining[start + 3..];
let after = if let Some(nl) = after.find('\n') { &after[nl+1..] } else { after };
if let Some(end) = after.find("```") { &after[..end] } else { after }
} else { remaining.as_str() };
let full_path = project_dir.join(file_path);
if let Some(parent) = full_path.parent() { let _ = fs::create_dir_all(parent); }
if fs::write(&full_path, content.trim()).is_ok() {
step_files.push(file_path.to_string());
state.files.push(file_path.to_string());
}
}
}
state.completed.push(num);
let _ = fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap_or_default());
summaries.push(format!("{}. {} ✓", num, step));
sse_ev(&mut stream, "status", &sse_j(&serde_json::json!({"text": format!("Step {} done — {} files", num, step_files.len()), "agent": "coder"}))).await;
}
Err(e) => {
summaries.push(format!("{}. {} ✗", num, step));
failed = true;
let _ = fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap_or_default());
let err_esc = e.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
sse_ev(&mut stream, "message", &format!(
"{{\"content\":\"Step {} failed: {}\\n\\nYou can resume from here.\",\"agent\":\"supervisor\"}}",
num, err_esc
)).await;
sse_ev(&mut stream, "plan_failed", &format!(
"{{\"file\":\"{}\",\"completed\":{},\"total\":{},\"failed_step\":{}}}",
exec_req.plan_file.replace('\\', "\\\\").replace('"', "\\\""),
state.completed.len(), total, num
)).await;
break; }
}
}
let summary_lines = summaries.iter().map(|s| format!("- {}", s.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\\n");
if failed {
sse_ev(&mut stream, "message", &format!(
"{{\"content\":\"Progress so far:\\n\\n{}\\n\\n{} files created. Resume when ready.\",\"agent\":\"supervisor\",\"files_changed\":{}}}",
summary_lines, state.files.len(), serde_json::to_string(&state.files).unwrap_or_default()
)).await;
} else {
let _ = fs::remove_file(&state_path);
sse_ev(&mut stream, "message", &format!(
"{{\"content\":\"All done!\\n\\n{}\\n\\n{} files created or updated.\",\"agent\":\"supervisor\",\"files_changed\":{}}}",
summary_lines, state.files.len(), serde_json::to_string(&state.files).unwrap_or_default()
)).await;
}
sse_ev(&mut stream, "done", "{}").await;
} else if first_line.starts_with("OPTIONS") {
let resp = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Max-Age: 86400\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
} else {
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
}
});
}
}
fn chrono_now() -> String {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = d.as_secs();
format!("{}Z", secs)
}