//! Office — an LLM-run company. Straight top-down, axis-aligned, barrier //! stepping: every round all agents decide via LLM while the scene freezes, //! then the whole scene steps 30 sim-minutes at once. //! //! The engine lives in lib.rs (src/*). This binary is the graphic window; //! `--headless` runs the same loop with no renderer, paired with the TUI. use office::{company, llm, panel, people, round, scene, sim, shared, tasks, verify}; use office::shared::{HumanInbox, UIFont}; use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}; use bevy::prelude::*; use bevy::window::PrimaryWindow; use company::{CompanyCfg, Workspace}; use llm::LlmPool; use people::{Memory, Person, Selected, Walking}; use round::{collect_results, gather_due}; use scene::{spawn_office, Spots}; use sim::*; use tasks::TaskBoard; fn main() { let mut company_dir = std::path::PathBuf::from("companies/starter"); let mut use_remote = true; let mut split = false; let mut no_local = true; // home GPU idle by default; --local re-enables let mut coder_url = llm::LLAMA_CODER_URL.to_string(); let mut coder_model = llm::LLAMA_CODER_MODEL.to_string(); let mut headless = false; let mut all_27b = false; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--no-remote" => use_remote = false, // remote-only is the default now; "--no-local" kept for old habits "--no-local" => no_local = true, // run models on the home machine (9070 / llama.cpp / local ollama) "--local" => no_local = false, // 2060 talks (decisions), local big model writes work logs. "--split" => split = true, // EVERYTHING on the home llama.cpp 27B (decide + write). "--27b" => { use_remote = false; split = true; no_local = false; all_27b = true; } "--coder-url" => coder_url = args.next().unwrap_or_default(), "--coder-model" => coder_model = args.next().unwrap_or_default(), "--company" => { if let Some(dir) = args.next() { company_dir = dir.into(); } } "--headless" => headless = true, other if !other.starts_with("--") => company_dir = other.into(), other => { eprintln!( "unknown flag: {other}\nusage: office [--company ] [--split] [--no-local] \ [--no-remote]" ); std::process::exit(2); } } } let cfg = CompanyCfg::load(&resolve_company_dir(&company_dir)).unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1); }); let company_dir = resolve_company_dir(&company_dir); let workspace = Workspace::new(&company_dir).unwrap_or_else(|e| { eprintln!("error creating workspace: {e}"); std::process::exit(1); }); let board = TaskBoard::load_or_init(&workspace.root.join("tasks.json"), &cfg); info!( "company: {} — {} people, {} backlog tasks", cfg.company.name, cfg.people.len(), board.tasks.len() ); // Day 1 starts with everyone gathered for standup. let mut events = CompanyEvents::default(); events.meeting = Some(Meeting { topic: "Morning standup".into(), until: DAY_START + 30.0, }); // Route: where each kind of job may run. let route = if all_27b { // Full 27B mode: every decision AND every write is the home llama.cpp // server (its own queue handles concurrency: 3 decide + 2 write). llm::Route { decide: vec![llm::Endpoint::new(&coder_url, &coder_model, 3)], write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)], } } else if split && use_remote { // Split mode: the 2060 does ALL talking/deciding, the local 27b // only writes work logs (heavier, fewer calls). llm::Route { decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)], write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)], } } else if split && no_local { // Split, home GPU off: BOTH queues ride the 2060 (its Ollama queues // concurrent calls internally, so one endpoint is fine). llm::Route { decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)], write: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 2)], } } else if split { // Split without remote: everything local (spark decides, 27b writes). llm::Route { decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4)], write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)], } } else if use_remote && no_local { llm::Route { decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 6)], write: vec![], } } else if use_remote { // Shared: round-robin across both machines for every job. llm::Route { decide: vec![ llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4), llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3), ], write: vec![], } } else { llm::Route { decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 6)], write: vec![], } }; info!( "route: {} | {}", route .decide .iter() .map(|e| format!("decide ← {}", e.label())) .collect::>() .join(", "), if route.write.is_empty() { "shared mode (work notes inline)".to_string() } else { route .write .iter() .map(|e| format!("write ← {}", e.label())) .collect::>() .join(", ") } ); // ── headless: same loop, no renderer — pairs with the office-tui bin ── if headless { let spots0 = scene::headless_spots(&cfg); App::new() .add_plugins(bevy::MinimalPlugins.set(bevy::app::ScheduleRunnerPlugin::run_loop( std::time::Duration::from_millis(50), ))) .add_plugins(bevy::log::LogPlugin::default()) .insert_resource(spots0) .insert_resource(SimClock(if board.sim_minutes > 0.01 { board.sim_minutes } else { DAY_START })) .insert_resource(RoundState { step_until: 0.0, ..default() }) .insert_resource(events) .insert_resource(Phase::Stepping) .insert_resource(cfg) .insert_resource(workspace) .insert_resource(board) .insert_resource(Selected(None)) .insert_resource(crate::shared::Bonds::default()) .insert_resource(route) .add_systems( Startup, (people::spawn_people_headless, start_pool, start_stdin).chain(), ) .add_systems( Update, (gather_due, collect_results, round::human_commands).chain(), ) .run(); return; } App::new() .insert_resource(ClearColor(Color::srgb(0.12, 0.12, 0.14))) .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: format!("Office — {}", cfg.company.name), resolution: (1600, 950).into(), ..default() }), ..default() })) .add_plugins(FrameTimeDiagnosticsPlugin::default()) .insert_resource(SimClock(if board.sim_minutes > 0.01 { board.sim_minutes } else { DAY_START })) .insert_resource(RoundState { step_until: 0.0, // first gather fires immediately ..default() }) .insert_resource(events) .insert_resource(Phase::Stepping) .insert_resource(cfg) .insert_resource(workspace) .insert_resource(board) .insert_resource(Selected(None)) .insert_resource(crate::shared::Bonds::default()) .init_resource::() .insert_resource(route) .add_systems( Startup, ( start_font, spawn_office, people::spawn_people, snap_to_standup_seats, start_pool, start_stdin, ) .chain(), ) .add_systems( Update, ( gather_due, collect_results, crate::round::human_commands, move_people.after(gather_due), people::update_bubbles, click_select, hud, panel::panel, ) .chain(), ) .run(); } /// Resolve a company dir relative to cwd first, then relative to the /// executable (walking up a few levels) — so the sim runs from any cwd. fn resolve_company_dir(given: &std::path::Path) -> std::path::PathBuf { if given.join("company.toml").exists() { return given.to_path_buf(); } if let Ok(exe) = std::env::current_exe() { let mut dir = exe .parent() .map(|d| d.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from("/")); for _ in 0..6 { let candidate = dir.join(given); if candidate.join("company.toml").exists() { info!("company dir: {}", candidate.display()); return candidate; } match dir.parent() { Some(p) => dir = p.to_path_buf(), None => break, } } } given.to_path_buf() // fall through; load() reports the real error } fn start_font(mut commands: Commands, server: Res) { // AssetServer paths are relative to ./assets — copy the system font in, // then load it normally (absolute paths silently fail to resolve). let candidates = [ "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf", ]; let best = candidates .iter() .find(|p| std::path::Path::new(p).exists()); match best { Some(font_path) => { // Bevy's asset root sits next to the executable, not the cwd. let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.to_path_buf())) .unwrap_or_else(|| std::path::PathBuf::from(".")); let local = exe_dir.join("assets/fonts/DejaVuSans.ttf"); let _ = std::fs::create_dir_all(local.parent().unwrap()); if !local.exists() { match std::fs::copy(font_path, &local) { Ok(_) => info!("font copied to {}", local.display()), Err(e) => warn!("font copy failed: {e}"), } } info!("font: {}", local.display()); commands.insert_resource(UIFont(server.load("fonts/DejaVuSans.ttf"))); } None => { warn!("DejaVu not found — non-ASCII glyphs will render as boxes"); commands.insert_resource(UIFont(server.load("fonts/none.ttf"))); } } } fn start_stdin(mut commands: Commands) { let (tx, rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { use std::io::BufRead; for line in std::io::stdin().lock().lines() { match line { Ok(l) => { if tx.send(l).is_err() { break; } } Err(_) => break, } } }); commands.insert_resource(HumanInbox(std::sync::Mutex::new(rx))); println!("manager console ready — type help for commands (sim runs without you)"); } fn start_pool(mut commands: Commands, route: Res, cfg: Res) { let roster: Vec<(String, String, String)> = cfg .people .iter() .map(|p| (p.name.clone(), p.role.clone(), p.personality.clone())) .collect(); llm::start_pool(&mut commands, route.clone(), roster); } /// One-shot: send everyone to their meeting seat for day-1 standup. fn snap_to_standup_seats(spots: Res, mut query: Query<(&Person, &mut Walking)>, mut done: Local) { if *done { return; } let mut seat = 0; for (_, mut walking) in &mut query { walking.0 = spots .meeting_seats .get(seat % spots.meeting_seats.len().max(1)) .copied(); seat += 1; } *done = true; } // --------------------------------------------------------------------------- // Movement / selection / HUD // --------------------------------------------------------------------------- fn move_people( time: Res