diff --git a/crates/kild-config/src/lib.rs b/crates/kild-config/src/lib.rs index 65ab3add..728b7b8e 100644 --- a/crates/kild-config/src/lib.rs +++ b/crates/kild-config/src/lib.rs @@ -21,8 +21,8 @@ pub use include_config::{CopyOptions, IncludeConfig, PatternRule, default_includ pub use keybindings::{Keybindings, NavigationKeybindings, TerminalKeybindings}; pub use loading::{get_agent_command, load_hierarchy, merge_configs}; pub use types::{ - AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, GitConfig, HealthConfig, - KildConfig, TerminalConfig, UiConfig, + AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, FleetConfig, GitConfig, + HealthConfig, KildConfig, TerminalConfig, UiConfig, }; pub use validation::{VALID_TERMINALS, validate_config}; diff --git a/crates/kild-config/src/loading.rs b/crates/kild-config/src/loading.rs index 7a35a844..7231e147 100644 --- a/crates/kild-config/src/loading.rs +++ b/crates/kild-config/src/loading.rs @@ -14,7 +14,8 @@ use crate::agent_data; use crate::include_config::IncludeConfig; use crate::types::{ - AgentConfig, DaemonRuntimeConfig, GitConfig, HealthConfig, KildConfig, TerminalConfig, UiConfig, + AgentConfig, DaemonRuntimeConfig, FleetConfig, GitConfig, HealthConfig, KildConfig, + TerminalConfig, UiConfig, }; use crate::validation::validate_config; use std::fs; @@ -174,6 +175,7 @@ pub fn merge_configs(base: KildConfig, override_config: KildConfig) -> KildConfi editor: base.editor.merge(override_config.editor), daemon: DaemonRuntimeConfig::merge(&base.daemon, &override_config.daemon), ui: UiConfig::merge(&base.ui, &override_config.ui), + fleet: FleetConfig::merge(&base.fleet, &override_config.fleet), } } diff --git a/crates/kild-config/src/types.rs b/crates/kild-config/src/types.rs index 5fb974d1..c93c7587 100644 --- a/crates/kild-config/src/types.rs +++ b/crates/kild-config/src/types.rs @@ -108,6 +108,10 @@ pub struct KildConfig { /// UI configuration (keybindings, navigation). #[serde(default)] pub ui: UiConfig, + + /// Fleet configuration (channels, communication). + #[serde(default)] + pub fleet: FleetConfig, } impl Default for KildConfig { @@ -122,6 +126,7 @@ impl Default for KildConfig { editor: ::default(), daemon: DaemonRuntimeConfig::default(), ui: UiConfig::default(), + fleet: FleetConfig::default(), } } } @@ -141,6 +146,29 @@ impl UiConfig { } } +/// Fleet configuration for inter-agent communication. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FleetConfig { + /// Enable MCP channel server for near-real-time fleet communication. + /// Requires Bun runtime. Default: false (research preview). + pub channels: Option, +} + +impl FleetConfig { + /// Whether fleet channels are enabled (default: false). + pub fn channels(&self) -> bool { + self.channels.unwrap_or(false) + } + + /// Merge two fleet configs. Override takes precedence for set fields. + pub fn merge(base: &Self, override_config: &Self) -> Self { + Self { + channels: override_config.channels.or(base.channels), + } + } +} + /// Daemon runtime configuration. /// /// Controls whether the daemon is the default runtime for new sessions diff --git a/crates/kild-core/src/lib.rs b/crates/kild-core/src/lib.rs index 5c5c2244..eb623b14 100644 --- a/crates/kild-core/src/lib.rs +++ b/crates/kild-core/src/lib.rs @@ -47,8 +47,8 @@ pub use git::types::{ }; pub use kild_config::ConfigError; pub use kild_config::{ - AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, GitConfig, HealthConfig, - Keybindings, KildConfig, TerminalConfig, UiConfig, VALID_TERMINALS, + AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, FleetConfig, GitConfig, + HealthConfig, Keybindings, KildConfig, TerminalConfig, UiConfig, VALID_TERMINALS, }; pub use kild_config::{CopyOptions, IncludeConfig, PatternRule}; pub use projects::{Project, ProjectError, ProjectRegistry, ProjectsData}; diff --git a/crates/kild-core/src/sessions/daemon_helpers.rs b/crates/kild-core/src/sessions/daemon_helpers.rs index 18982ea3..89aadf80 100644 --- a/crates/kild-core/src/sessions/daemon_helpers.rs +++ b/crates/kild-core/src/sessions/daemon_helpers.rs @@ -22,7 +22,8 @@ pub use super::attach::spawn_and_save_attach_window; // Shim setup pub(crate) use super::shim_setup::ensure_shim_binary; -// Agent integrations — public ensure functions (used by CLI init-hooks) +// Agent integrations — public ensure functions (used by CLI init-hooks/init-channels) +pub use super::integrations::channel::ensure_channel_server_installed; pub use super::integrations::{ ensure_claude_settings, ensure_claude_status_hook, ensure_opencode_config, ensure_opencode_package_json, ensure_opencode_plugin_in_worktree, diff --git a/crates/kild-core/src/sessions/daemon_spawn.rs b/crates/kild-core/src/sessions/daemon_spawn.rs index d9fbcc62..680c90de 100644 --- a/crates/kild-core/src/sessions/daemon_spawn.rs +++ b/crates/kild-core/src/sessions/daemon_spawn.rs @@ -16,8 +16,8 @@ use kild_config::{Config, KildConfig}; use super::daemon_request::build_daemon_create_request; use super::integrations::{ - setup_claude_integration, setup_codex_integration, setup_fleet_instructions, - setup_opencode_integration, + setup_channel_integration, setup_claude_integration, setup_codex_integration, + setup_fleet_instructions, setup_opencode_integration, }; use super::{fleet, inbox}; @@ -63,6 +63,13 @@ pub(super) fn spawn_daemon_agent( setup_opencode_integration(params.agent, params.worktree_path); setup_claude_integration(params.agent); setup_fleet_instructions(params.agent, params.worktree_path, params.use_main_worktree); + setup_channel_integration( + params.agent, + params.worktree_path, + params.branch, + params.use_main_worktree, + params.kild_config, + ); // 3. Fleet member + inbox setup fleet::ensure_fleet_member(params.branch, params.worktree_path, params.agent); @@ -72,10 +79,12 @@ pub(super) fn spawn_daemon_agent( inbox::ensure_inbox(&paths, params.project_id, params.branch, params.agent); // 4. Fleet agent flags → augmented command - let fleet_command = match fleet::fleet_agent_flags(params.branch, params.agent) { - Some(flags) => format!("{} {}", params.agent_command, flags), - None => params.agent_command.to_string(), - }; + let channels_enabled = params.kild_config.fleet.channels(); + let fleet_command = + match fleet::fleet_agent_flags(params.branch, params.agent, channels_enabled) { + Some(flags) => format!("{} {}", params.agent_command, flags), + None => params.agent_command.to_string(), + }; // 5. Build daemon create request let mut req_params = build_daemon_create_request( diff --git a/crates/kild-core/src/sessions/destroy.rs b/crates/kild-core/src/sessions/destroy.rs index 458da9bb..4be62006 100644 --- a/crates/kild-core/src/sessions/destroy.rs +++ b/crates/kild-core/src/sessions/destroy.rs @@ -314,6 +314,11 @@ pub fn destroy_session(name: &str, force: bool) -> Result<(), SessionError> { // 3e. Clean up fleet inbox file and team config entry super::fleet::remove_fleet_member(&session.branch); + // 3f. Clean up .mcp.json for --main sessions (worktree deletion handles non-main) + if session.use_main_worktree { + super::integrations::channel::cleanup_mcp_json(&session.worktree_path); + } + // 4. Resolve main repo path before worktree removal (needed for branch cleanup) let main_repo_path = git::removal::find_main_repo_root(&session.worktree_path); diff --git a/crates/kild-core/src/sessions/fleet.rs b/crates/kild-core/src/sessions/fleet.rs index bcfa7c77..0a200655 100644 --- a/crates/kild-core/src/sessions/fleet.rs +++ b/crates/kild-core/src/sessions/fleet.rs @@ -89,13 +89,13 @@ pub fn fleet_mode_active(branch: &str) -> bool { /// /// Returns None if fleet mode does not apply (wrong agent, not active, etc.). /// The returned string is appended to the existing agent command. -pub fn fleet_agent_flags(branch: &str, agent: &str) -> Option { +pub fn fleet_agent_flags(branch: &str, agent: &str, channels_enabled: bool) -> Option { if !is_claude_fleet_agent(agent) || !fleet_mode_active(branch) { return None; } let safe_name = fleet_safe_name(branch); - let flags = if branch == BRAIN_BRANCH { + let mut flags = if branch == BRAIN_BRANCH { // Brain loads the kild-brain agent definition and joins as team lead. format!( "--agent kild-brain --agent-id {safe_name}@{TEAM_NAME} \ @@ -107,6 +107,10 @@ pub fn fleet_agent_flags(branch: &str, agent: &str) -> Option { ) }; + if channels_enabled { + flags.push_str(" --dangerously-load-development-channels server:kild-fleet"); + } + Some(flags) } @@ -604,7 +608,7 @@ mod tests { #[test] fn fleet_agent_flags_brain_gets_kild_brain_flag() { with_team_dir("brain_flag", |_| { - let flags = fleet_agent_flags(BRAIN_BRANCH, "claude").unwrap(); + let flags = fleet_agent_flags(BRAIN_BRANCH, "claude", false).unwrap(); assert!( flags.contains("--agent kild-brain"), "brain should get --agent kild-brain, got: {}", @@ -621,7 +625,7 @@ mod tests { #[test] fn fleet_agent_flags_worker_does_not_get_brain_flag() { with_team_dir("worker_no_brain_flag", |_| { - let flags = fleet_agent_flags("my-feature", "claude").unwrap(); + let flags = fleet_agent_flags("my-feature", "claude", false).unwrap(); assert!( !flags.contains("--agent kild-brain"), "worker should not get --agent kild-brain, got: {}", @@ -638,7 +642,7 @@ mod tests { #[test] fn fleet_agent_flags_slashed_branch_sanitized_in_agent_name() { with_team_dir("slashed_branch_flags", |_| { - let flags = fleet_agent_flags("refactor/consolidate-ipc", "claude").unwrap(); + let flags = fleet_agent_flags("refactor/consolidate-ipc", "claude", false).unwrap(); assert!( flags.contains("--agent-id refactor-consolidate-ipc@honryu"), "slashed branch should be sanitized in agent-id, got: {}", @@ -660,10 +664,10 @@ mod tests { #[test] fn fleet_agent_flags_non_claude_returns_none() { with_team_dir("non_claude_none", |_| { - assert!(fleet_agent_flags("my-feature", "amp").is_none()); - assert!(fleet_agent_flags("my-feature", "codex").is_none()); - assert!(fleet_agent_flags("my-feature", "kiro").is_none()); - assert!(fleet_agent_flags("my-feature", "gemini").is_none()); + assert!(fleet_agent_flags("my-feature", "amp", false).is_none()); + assert!(fleet_agent_flags("my-feature", "codex", false).is_none()); + assert!(fleet_agent_flags("my-feature", "kiro", false).is_none()); + assert!(fleet_agent_flags("my-feature", "gemini", false).is_none()); }); } @@ -671,7 +675,7 @@ mod tests { fn fleet_agent_flags_returns_none_when_no_team_dir_and_not_brain() { without_team_dir("no_dir_worker", |_| { assert!( - fleet_agent_flags("my-feature", "claude").is_none(), + fleet_agent_flags("my-feature", "claude", false).is_none(), "should be None when team dir absent and branch is not brain" ); }); @@ -681,7 +685,7 @@ mod tests { fn fleet_agent_flags_brain_returns_flags_even_without_team_dir() { without_team_dir("no_dir_brain", |_| { // Brain creates the team — fleet activates unconditionally for the brain branch. - let flags = fleet_agent_flags(BRAIN_BRANCH, "claude"); + let flags = fleet_agent_flags(BRAIN_BRANCH, "claude", false); assert!( flags.is_some(), "brain should get flags even when team dir absent" diff --git a/crates/kild-core/src/sessions/inbox.rs b/crates/kild-core/src/sessions/inbox.rs index 3a878e09..3ad05b62 100644 --- a/crates/kild-core/src/sessions/inbox.rs +++ b/crates/kild-core/src/sessions/inbox.rs @@ -24,6 +24,9 @@ pub struct InboxState { pub status: String, pub task: Option, pub report: Option, + /// Whether the MCP channel server is connected (`.channel` breadcrumb exists). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub channel_connected: bool, } /// A single session's fleet status for the prime context. @@ -81,6 +84,12 @@ pub fn ensure_inbox(paths: &KildPaths, project_id: &str, branch: &str, agent: &s ); } + // Remove stale channel breadcrumb from previous session. + let channel_file = inbox_dir.join(".channel"); + if channel_file.exists() { + let _ = std::fs::remove_file(&channel_file); + } + info!( event = "core.fleet.inbox_ensured", branch = branch, @@ -141,12 +150,14 @@ pub fn read_inbox_state(project_id: &str, branch: &str) -> Result.', + 'You are the Honryū brain. React to worker status changes and reports.', + 'Use send_to_worker to assign tasks. Use list_fleet to see all workers.', + ].join(' ') + : [ + 'Fleet events arrive as .', + `You are worker "${BRANCH}". When you receive a task, use report_status("working") to acknowledge,`, + 'then execute it. When done, use report_status("done", "your report here").', + 'Use send_to_brain to message the brain supervisor.', + ].join(' '), + }, +) + +// --- Tools --- + +const WORKER_TOOLS = [ + { + name: 'report_status', + description: 'Update your fleet status and optionally write a completion report.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', enum: ['idle', 'working', 'done', 'blocked'], description: 'Your current status' }, + report: { type: 'string', description: 'Task report content (written to report.md). Include when status is done.' }, + }, + required: ['status'], + }, + }, + { + name: 'send_to_brain', + description: 'Send a message to the brain supervisor (writes to brain inbox).', + inputSchema: { + type: 'object' as const, + properties: { + text: { type: 'string', description: 'Message text' }, + }, + required: ['text'], + }, + }, +] + +const BRAIN_TOOLS = [ + { + name: 'report_status', + description: 'Update your fleet status.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', enum: ['idle', 'working', 'done', 'blocked'] }, + }, + required: ['status'], + }, + }, + { + name: 'send_to_worker', + description: 'Send a task or message to a fleet worker (writes task.md to their inbox).', + inputSchema: { + type: 'object' as const, + properties: { + branch: { type: 'string', description: 'Worker branch name' }, + text: { type: 'string', description: 'Task or message text' }, + }, + required: ['branch', 'text'], + }, + }, +] + +const COMMON_TOOLS = [ + { + name: 'list_fleet', + description: 'List all fleet members with their current inbox status.', + inputSchema: { type: 'object' as const, properties: {} }, + }, +] + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...(IS_BRAIN ? BRAIN_TOOLS : WORKER_TOOLS), ...COMMON_TOOLS], +})) + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + + switch (name) { + case 'report_status': { + if (!INBOX) return { content: [{ type: 'text', text: 'No KILD_INBOX set' }] } + writeFileSync(join(INBOX, 'status'), args.status) + if (args.report) { + writeFileSync(join(INBOX, 'report.md'), args.report) + } + return { content: [{ type: 'text', text: `Status updated to ${args.status}` }] } + } + + case 'send_to_brain': { + if (!FLEET_DIR && !INBOX) return { content: [{ type: 'text', text: 'No fleet directory available' }] } + // Worker: FLEET_DIR is not set, but we can derive brain inbox from INBOX path + // INBOX = ~/.kild/inbox//worker-branch, brain = ~/.kild/inbox//honryu + const projectDir = dirname(INBOX!) + const brainInbox = join(projectDir, 'honryu') + if (!existsSync(brainInbox)) return { content: [{ type: 'text', text: 'Brain inbox not found — is honryu running?' }] } + const tmpPath = join(brainInbox, '.task.md.tmp') + writeFileSync(tmpPath, args.text) + const { renameSync } = await import('fs') + renameSync(tmpPath, join(brainInbox, 'task.md')) + return { content: [{ type: 'text', text: `Message sent to brain` }] } + } + + case 'send_to_worker': { + if (!FLEET_DIR) return { content: [{ type: 'text', text: 'Only the brain can send to workers' }] } + const safeBranch = args.branch.replace(/\//g, '_') + const workerInbox = join(FLEET_DIR, safeBranch) + if (!existsSync(workerInbox)) return { content: [{ type: 'text', text: `Worker inbox not found for '${args.branch}'` }] } + const tmpPath = join(workerInbox, '.task.md.tmp') + writeFileSync(tmpPath, args.text) + const { renameSync } = await import('fs') + renameSync(tmpPath, join(workerInbox, 'task.md')) + return { content: [{ type: 'text', text: `Task sent to ${args.branch}` }] } + } + + case 'list_fleet': { + const dir = FLEET_DIR || (INBOX ? dirname(INBOX) : null) + if (!dir || !existsSync(dir)) return { content: [{ type: 'text', text: 'No fleet directory found' }] } + const entries = readdirSync(dir) + .filter(f => { try { return statSync(join(dir, f)).isDirectory() } catch { return false } }) + .map(branch => { + const statusPath = join(dir, branch, 'status') + const status = existsSync(statusPath) ? readFileSync(statusPath, 'utf8').trim() : 'unknown' + const hasTask = existsSync(join(dir, branch, 'task.md')) + const hasReport = existsSync(join(dir, branch, 'report.md')) + return `${branch}: ${status}${hasTask ? ' [task]' : ''}${hasReport ? ' [report]' : ''}` + }) + return { content: [{ type: 'text', text: entries.length ? entries.join('\n') : 'No fleet members found' }] } + } + + default: + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] } + } +}) + +// --- Connect --- +await server.connect(new StdioServerTransport()) + +// Signal successful connection by writing a breadcrumb to the inbox dir. +// `kild inbox` or diagnostics can check for this file to confirm the channel loaded. +if (INBOX && existsSync(INBOX)) { + writeFileSync(join(INBOX, '.channel'), `connected ${new Date().toISOString()} pid=${process.pid}`) +} + +// --- File watching --- + +let debounce = new Map>() + +function pushNotification(branch: string, event: string, content: string) { + server.notification({ + method: 'notifications/claude/channel', + params: { + content, + meta: { branch, event, ts: new Date().toISOString() }, + }, + }).catch(() => {}) // best-effort +} + +function onFileChange(dir: string, filename: string | null) { + if (!filename || filename.startsWith('.')) return + const name = basename(filename) + if (!['task.md', 'status', 'report.md'].includes(name)) return + + // Debounce per file (100ms) — fs.watch can fire multiple events for one write + const key = join(dir, filename) + const existing = debounce.get(key) + if (existing) clearTimeout(existing) + debounce.set(key, setTimeout(() => { + debounce.delete(key) + try { + const filePath = join(dir, filename!) + if (!existsSync(filePath)) return + const content = readFileSync(filePath, 'utf8') + // For brain watching fleet dir: filename is "worker-branch/status" + // For worker watching own inbox: filename is "status" + const parts = filename!.split('/') + const branch = parts.length > 1 ? parts[0] : BRANCH + const event = name.replace('.md', '') + pushNotification(branch, event, content) + } catch {} + }, 100)) +} + +if (IS_BRAIN && FLEET_DIR && existsSync(FLEET_DIR)) { + watch(FLEET_DIR, { recursive: true }, (_, filename) => onFileChange(FLEET_DIR!, filename)) +} else if (INBOX && existsSync(INBOX)) { + watch(INBOX, {}, (_, filename) => onFileChange(INBOX!, filename)) +} +"#; + +/// Embedded package.json for the channel server. +const CHANNEL_PACKAGE_JSON: &str = r#"{ + "name": "kild-fleet-channel", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0" + } +} +"#; + +/// Ensure the kild-fleet channel server is installed at `~/.kild/channels/fleet/`. +/// +/// Always overwrites `server.ts` to pick up updates. Only writes `package.json` +/// if missing (preserving installed node_modules). +pub fn ensure_channel_server_installed(paths: &KildPaths) -> Result<(), String> { + let fleet_dir = paths.fleet_channel_dir(); + + std::fs::create_dir_all(&fleet_dir) + .map_err(|e| format!("failed to create {}: {}", fleet_dir.display(), e))?; + + // Always overwrite server.ts (same pattern as claude-status hook). + let server_path = fleet_dir.join("server.ts"); + std::fs::write(&server_path, CHANNEL_SERVER) + .map_err(|e| format!("failed to write {}: {}", server_path.display(), e))?; + + // Only write package.json if missing (don't clobber installed deps). + let package_path = fleet_dir.join("package.json"); + if !package_path.exists() { + std::fs::write(&package_path, CHANNEL_PACKAGE_JSON) + .map_err(|e| format!("failed to write {}: {}", package_path.display(), e))?; + } + + info!( + event = "core.fleet.channel_server_installed", + path = %fleet_dir.display(), + ); + + Ok(()) +} + +/// Ensure `.mcp.json` in the worktree contains the `kild-fleet` channel server entry. +/// +/// Idempotent — skips if the entry already exists. +pub fn ensure_mcp_json(worktree_path: &Path, paths: &KildPaths) -> Result<(), String> { + let mcp_path = worktree_path.join(".mcp.json"); + let server_ts = paths.fleet_channel_dir().join("server.ts"); + let server_ts_str = server_ts.display().to_string(); + + let mut config: serde_json::Value = if mcp_path.exists() { + let content = std::fs::read_to_string(&mcp_path) + .map_err(|e| format!("failed to read {}: {}", mcp_path.display(), e))?; + serde_json::from_str(&content).map_err(|e| { + format!( + "failed to parse {}: {} — fix JSON syntax or remove the file to reset", + mcp_path.display(), + e + ) + })? + } else { + serde_json::json!({}) + }; + + let servers = config + .as_object_mut() + .ok_or(".mcp.json root is not an object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + + let servers_obj = servers + .as_object_mut() + .ok_or("\"mcpServers\" field in .mcp.json is not an object")?; + + if servers_obj.contains_key("kild-fleet") { + debug!(event = "core.fleet.mcp_json_already_configured"); + return Ok(()); + } + + servers_obj.insert( + "kild-fleet".to_string(), + serde_json::json!({ + "command": "bun", + "args": [server_ts_str] + }), + ); + + let content = serde_json::to_string_pretty(&config) + .map_err(|e| format!("failed to serialize .mcp.json: {}", e))?; + + std::fs::write(&mcp_path, format!("{}\n", content)) + .map_err(|e| format!("failed to write {}: {}", mcp_path.display(), e))?; + + info!( + event = "core.fleet.mcp_json_patched", + path = %mcp_path.display(), + ); + + Ok(()) +} + +/// Remove the `kild-fleet` entry from `.mcp.json` in the worktree. +/// +/// Best-effort — for `--main` sessions where the worktree is the project root. +/// Deletes the file entirely if it only contained the kild-fleet entry. +pub fn cleanup_mcp_json(worktree_path: &Path) { + let mcp_path = worktree_path.join(".mcp.json"); + if !mcp_path.exists() { + return; + } + + let content = match std::fs::read_to_string(&mcp_path) { + Ok(c) => c, + Err(_) => return, + }; + + let mut config: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return, + }; + + let removed = config + .get_mut("mcpServers") + .and_then(|s| s.as_object_mut()) + .map(|servers| servers.remove("kild-fleet").is_some()) + .unwrap_or(false); + + if !removed { + return; + } + + // If mcpServers is now empty, remove the file entirely. + let is_empty = config + .get("mcpServers") + .and_then(|s| s.as_object()) + .is_some_and(|s| s.is_empty()); + + if is_empty { + let _ = std::fs::remove_file(&mcp_path); + info!( + event = "core.fleet.mcp_json_removed", + path = %mcp_path.display(), + ); + } else { + if let Ok(json) = serde_json::to_string_pretty(&config) { + let _ = std::fs::write(&mcp_path, format!("{}\n", json)); + } + info!( + event = "core.fleet.mcp_json_entry_removed", + path = %mcp_path.display(), + ); + } +} + +/// Check if `bun` is available in PATH. +fn bun_available() -> bool { + std::process::Command::new("bun") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Set up the MCP channel integration for a fleet session. +/// +/// Best-effort — warns on failure but never blocks session creation. +/// No-op when: fleet mode inactive, channels disabled in config, agent is not +/// claude, or Bun is not installed. +pub(crate) fn setup_channel_integration( + agent: &str, + worktree_path: &Path, + branch: &str, + _is_main_worktree: bool, + config: &KildConfig, +) { + // Guard: only for claude agents in fleet mode with channels enabled. + if agent != "claude" { + return; + } + if !fleet::fleet_mode_active(branch) { + return; + } + if !config.fleet.channels() { + return; + } + if !bun_available() { + warn!(event = "core.fleet.channel.bun_not_found"); + eprintln!( + "Warning: Bun not found — fleet channel server requires Bun. \ + Install from https://bun.sh or run `kild init-channels` after installing." + ); + return; + } + + let paths = match KildPaths::resolve() { + Ok(p) => p, + Err(e) => { + warn!(event = "core.fleet.channel.paths_failed", error = %e); + return; + } + }; + + if let Err(e) = ensure_channel_server_installed(&paths) { + warn!(event = "core.fleet.channel.install_failed", error = %e); + eprintln!("Warning: Failed to install fleet channel server: {e}"); + return; + } + + if let Err(e) = ensure_mcp_json(worktree_path, &paths) { + warn!(event = "core.fleet.channel.mcp_json_failed", error = %e); + eprintln!("Warning: Failed to configure .mcp.json: {e}"); + } +} diff --git a/crates/kild-core/src/sessions/integrations/mod.rs b/crates/kild-core/src/sessions/integrations/mod.rs index f3eeff01..f868d012 100644 --- a/crates/kild-core/src/sessions/integrations/mod.rs +++ b/crates/kild-core/src/sessions/integrations/mod.rs @@ -1,9 +1,11 @@ +pub mod channel; pub mod claude; pub mod codex; mod fleet_instructions; pub mod opencode; // Re-export setup orchestrators used by create.rs and open.rs +pub(crate) use channel::setup_channel_integration; pub(crate) use claude::setup_claude_integration; pub(crate) use codex::setup_codex_integration; pub(crate) use fleet_instructions::setup_fleet_instructions; diff --git a/crates/kild-paths/src/lib.rs b/crates/kild-paths/src/lib.rs index 26c0f2bc..69e155cb 100644 --- a/crates/kild-paths/src/lib.rs +++ b/crates/kild-paths/src/lib.rs @@ -64,6 +64,16 @@ impl KildPaths { self.kild_dir.join("health_history") } + // --- Channel paths --- + + pub fn channels_dir(&self) -> PathBuf { + self.kild_dir.join("channels") + } + + pub fn fleet_channel_dir(&self) -> PathBuf { + self.channels_dir().join("fleet") + } + // --- Inbox paths --- pub fn inbox_base_dir(&self) -> PathBuf { @@ -513,6 +523,22 @@ mod tests { ); } + #[test] + fn test_channels_dir() { + assert_eq!( + test_paths().channels_dir(), + PathBuf::from("/home/user/.kild/channels") + ); + } + + #[test] + fn test_fleet_channel_dir() { + assert_eq!( + test_paths().fleet_channel_dir(), + PathBuf::from("/home/user/.kild/channels/fleet") + ); + } + #[test] fn test_inbox_base_dir() { assert_eq!( diff --git a/crates/kild/src/app/misc.rs b/crates/kild/src/app/misc.rs index 06a028f8..c6f9bf69 100644 --- a/crates/kild/src/app/misc.rs +++ b/crates/kild/src/app/misc.rs @@ -222,6 +222,16 @@ pub fn init_hooks_command() -> Command { ) } +pub fn init_channels_command() -> Command { + Command::new("init-channels") + .about("Install the fleet MCP channel server for real-time agent communication") + .long_about( + "Installs the kild-fleet MCP channel server at ~/.kild/channels/fleet/ and runs \ + bun install. Requires Bun (https://bun.sh). Enable with [fleet] channels = true \ + in ~/.kild/config.toml.", + ) +} + pub fn code_command() -> Command { Command::new("code") .about("Open kild's worktree in your code editor") diff --git a/crates/kild/src/app/mod.rs b/crates/kild/src/app/mod.rs index 73e2d3c6..a2248591 100644 --- a/crates/kild/src/app/mod.rs +++ b/crates/kild/src/app/mod.rs @@ -42,5 +42,6 @@ pub fn build_cli() -> Command { .subcommand(daemon::inject_command()) .subcommand(misc::completions_command()) .subcommand(misc::init_hooks_command()) + .subcommand(misc::init_channels_command()) .subcommand(project::project_command()) } diff --git a/crates/kild/src/commands/inbox.rs b/crates/kild/src/commands/inbox.rs index 0ad89009..e1a77925 100644 --- a/crates/kild/src/commands/inbox.rs +++ b/crates/kild/src/commands/inbox.rs @@ -150,7 +150,16 @@ fn inbox_output_from_state(state: &InboxState) -> InboxOutput { } fn print_single_inbox(state: &InboxState) { - println!("Status: {}", color::aurora(&state.status)); + let channel_indicator = if state.channel_connected { + format!(" {}", color::aurora("[channel]")) + } else { + String::new() + }; + println!( + "Status: {}{}", + color::aurora(&state.status), + channel_indicator + ); let task_str = state .task diff --git a/crates/kild/src/commands/init_channels.rs b/crates/kild/src/commands/init_channels.rs new file mode 100644 index 00000000..f870fd48 --- /dev/null +++ b/crates/kild/src/commands/init_channels.rs @@ -0,0 +1,63 @@ +use clap::ArgMatches; +use tracing::{error, info}; + +pub(crate) fn handle_init_channels_command( + _matches: &ArgMatches, +) -> Result<(), Box> { + info!(event = "cli.init_channels_started"); + + let config = kild_core::Config::new(); + let paths = config.paths(); + + // 1. Install server.ts and package.json + kild_core::sessions::daemon_helpers::ensure_channel_server_installed(paths).map_err(|e| { + error!(event = "cli.init_channels_failed", error = %e); + Box::::from(e) + })?; + + let fleet_dir = paths.fleet_channel_dir().to_path_buf(); + println!("Channel server installed at {}", fleet_dir.display()); + + // 2. Check for bun + let bun_status = std::process::Command::new("bun") + .arg("--version") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output(); + + match bun_status { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!("Bun {version} found"); + } + _ => { + eprintln!("Warning: Bun not found. Install from https://bun.sh"); + eprintln!("The fleet channel server requires Bun to run."); + return Ok(()); + } + } + + // 3. Install dependencies (skip if node_modules already exists) + if fleet_dir.join("node_modules").exists() { + println!("Dependencies already installed."); + } else { + println!("Installing dependencies..."); + let install = std::process::Command::new("bun") + .args(["install", "--no-summary"]) + .current_dir(&fleet_dir) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| format!("Failed to run bun install: {}", e))?; + + if !install.success() { + return Err("bun install failed".into()); + } + } + + println!("\nFleet channel server ready."); + println!("Enable with: [fleet] channels = true in ~/.kild/config.toml"); + + info!(event = "cli.init_channels_completed"); + Ok(()) +} diff --git a/crates/kild/src/commands/mod.rs b/crates/kild/src/commands/mod.rs index 9c2ff902..6ee7fa6a 100644 --- a/crates/kild/src/commands/mod.rs +++ b/crates/kild/src/commands/mod.rs @@ -22,6 +22,7 @@ mod focus; mod health; mod hide; mod inbox; +mod init_channels; mod init_hooks; mod inject; mod list; @@ -72,6 +73,9 @@ pub fn run_command(matches: &ArgMatches) -> Result<(), Box inject::handle_inject_command(sub_matches), Some(("teammates", sub_matches)) => teammates::handle_teammates_command(sub_matches), Some(("init-hooks", sub_matches)) => init_hooks::handle_init_hooks_command(sub_matches), + Some(("init-channels", sub_matches)) => { + init_channels::handle_init_channels_command(sub_matches) + } Some(("project", sub_matches)) => project::handle_project_command(sub_matches), _ => { error!(event = "cli.command_unknown");