Skip to content

Commit c68fe80

Browse files
committed
feat(fleet): add MCP channel server for real-time agent communication
Add a kild-fleet MCP channel server that watches inbox files and pushes notifications into Claude Code sessions via the channels protocol. This reduces fleet communication latency from ~1s (Claude inbox polling) to ~100ms (fs.watch + stdio notification). The channel server (TypeScript/Bun) is embedded in the Rust binary and installed to ~/.kild/channels/fleet/ via `kild init-channels`. It exposes MCP tools (report_status, send_to_worker, send_to_brain, list_fleet) so agents can communicate without shelling out to CLI commands. Gated behind [fleet] channels = true config flag (default: false). Requires Bun runtime. Graceful degradation when unavailable. New command: `kild init-channels` — installs server + bun deps. New config: `[fleet] channels` — enables channel server for fleet sessions.
1 parent 369c35b commit c68fe80

15 files changed

Lines changed: 616 additions & 23 deletions

File tree

crates/kild-config/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ pub use include_config::{CopyOptions, IncludeConfig, PatternRule, default_includ
2121
pub use keybindings::{Keybindings, NavigationKeybindings, TerminalKeybindings};
2222
pub use loading::{get_agent_command, load_hierarchy, merge_configs};
2323
pub use types::{
24-
AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, GitConfig, HealthConfig,
25-
KildConfig, TerminalConfig, UiConfig,
24+
AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, FleetConfig, GitConfig,
25+
HealthConfig, KildConfig, TerminalConfig, UiConfig,
2626
};
2727
pub use validation::{VALID_TERMINALS, validate_config};
2828

crates/kild-config/src/loading.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
use crate::agent_data;
1515
use crate::include_config::IncludeConfig;
1616
use crate::types::{
17-
AgentConfig, DaemonRuntimeConfig, GitConfig, HealthConfig, KildConfig, TerminalConfig, UiConfig,
17+
AgentConfig, DaemonRuntimeConfig, FleetConfig, GitConfig, HealthConfig, KildConfig,
18+
TerminalConfig, UiConfig,
1819
};
1920
use crate::validation::validate_config;
2021
use std::fs;
@@ -174,6 +175,7 @@ pub fn merge_configs(base: KildConfig, override_config: KildConfig) -> KildConfi
174175
editor: base.editor.merge(override_config.editor),
175176
daemon: DaemonRuntimeConfig::merge(&base.daemon, &override_config.daemon),
176177
ui: UiConfig::merge(&base.ui, &override_config.ui),
178+
fleet: FleetConfig::merge(&base.fleet, &override_config.fleet),
177179
}
178180
}
179181

crates/kild-config/src/types.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ pub struct KildConfig {
108108
/// UI configuration (keybindings, navigation).
109109
#[serde(default)]
110110
pub ui: UiConfig,
111+
112+
/// Fleet configuration (channels, communication).
113+
#[serde(default)]
114+
pub fleet: FleetConfig,
111115
}
112116

113117
impl Default for KildConfig {
@@ -122,6 +126,7 @@ impl Default for KildConfig {
122126
editor: <EditorConfig as Default>::default(),
123127
daemon: DaemonRuntimeConfig::default(),
124128
ui: UiConfig::default(),
129+
fleet: FleetConfig::default(),
125130
}
126131
}
127132
}
@@ -141,6 +146,29 @@ impl UiConfig {
141146
}
142147
}
143148

149+
/// Fleet configuration for inter-agent communication.
150+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151+
#[serde(default)]
152+
pub struct FleetConfig {
153+
/// Enable MCP channel server for near-real-time fleet communication.
154+
/// Requires Bun runtime. Default: false (research preview).
155+
pub channels: Option<bool>,
156+
}
157+
158+
impl FleetConfig {
159+
/// Whether fleet channels are enabled (default: false).
160+
pub fn channels(&self) -> bool {
161+
self.channels.unwrap_or(false)
162+
}
163+
164+
/// Merge two fleet configs. Override takes precedence for set fields.
165+
pub fn merge(base: &Self, override_config: &Self) -> Self {
166+
Self {
167+
channels: override_config.channels.or(base.channels),
168+
}
169+
}
170+
}
171+
144172
/// Daemon runtime configuration.
145173
///
146174
/// Controls whether the daemon is the default runtime for new sessions

crates/kild-core/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ pub use git::types::{
4747
};
4848
pub use kild_config::ConfigError;
4949
pub use kild_config::{
50-
AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, GitConfig, HealthConfig,
51-
Keybindings, KildConfig, TerminalConfig, UiConfig, VALID_TERMINALS,
50+
AgentConfig, AgentSettings, Config, DaemonRuntimeConfig, EditorConfig, FleetConfig, GitConfig,
51+
HealthConfig, Keybindings, KildConfig, TerminalConfig, UiConfig, VALID_TERMINALS,
5252
};
5353
pub use kild_config::{CopyOptions, IncludeConfig, PatternRule};
5454
pub use projects::{Project, ProjectError, ProjectRegistry, ProjectsData};

crates/kild-core/src/sessions/daemon_helpers.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ pub use super::attach::spawn_and_save_attach_window;
2222
// Shim setup
2323
pub(crate) use super::shim_setup::ensure_shim_binary;
2424

25-
// Agent integrations — public ensure functions (used by CLI init-hooks)
25+
// Agent integrations — public ensure functions (used by CLI init-hooks/init-channels)
26+
pub use super::integrations::channel::ensure_channel_server_installed;
2627
pub use super::integrations::{
2728
ensure_claude_settings, ensure_claude_status_hook, ensure_opencode_config,
2829
ensure_opencode_package_json, ensure_opencode_plugin_in_worktree,

crates/kild-core/src/sessions/daemon_spawn.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ use kild_config::{Config, KildConfig};
1616

1717
use super::daemon_request::build_daemon_create_request;
1818
use super::integrations::{
19-
setup_claude_integration, setup_codex_integration, setup_fleet_instructions,
20-
setup_opencode_integration,
19+
setup_channel_integration, setup_claude_integration, setup_codex_integration,
20+
setup_fleet_instructions, setup_opencode_integration,
2121
};
2222
use super::{fleet, inbox};
2323

@@ -63,6 +63,13 @@ pub(super) fn spawn_daemon_agent(
6363
setup_opencode_integration(params.agent, params.worktree_path);
6464
setup_claude_integration(params.agent);
6565
setup_fleet_instructions(params.agent, params.worktree_path, params.use_main_worktree);
66+
setup_channel_integration(
67+
params.agent,
68+
params.worktree_path,
69+
params.branch,
70+
params.use_main_worktree,
71+
params.kild_config,
72+
);
6673

6774
// 3. Fleet member + inbox setup
6875
fleet::ensure_fleet_member(params.branch, params.worktree_path, params.agent);
@@ -72,10 +79,12 @@ pub(super) fn spawn_daemon_agent(
7279
inbox::ensure_inbox(&paths, params.project_id, params.branch, params.agent);
7380

7481
// 4. Fleet agent flags → augmented command
75-
let fleet_command = match fleet::fleet_agent_flags(params.branch, params.agent) {
76-
Some(flags) => format!("{} {}", params.agent_command, flags),
77-
None => params.agent_command.to_string(),
78-
};
82+
let channels_enabled = params.kild_config.fleet.channels();
83+
let fleet_command =
84+
match fleet::fleet_agent_flags(params.branch, params.agent, channels_enabled) {
85+
Some(flags) => format!("{} {}", params.agent_command, flags),
86+
None => params.agent_command.to_string(),
87+
};
7988

8089
// 5. Build daemon create request
8190
let mut req_params = build_daemon_create_request(

crates/kild-core/src/sessions/destroy.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ pub fn destroy_session(name: &str, force: bool) -> Result<(), SessionError> {
314314
// 3e. Clean up fleet inbox file and team config entry
315315
super::fleet::remove_fleet_member(&session.branch);
316316

317+
// 3f. Clean up .mcp.json for --main sessions (worktree deletion handles non-main)
318+
if session.use_main_worktree {
319+
super::integrations::channel::cleanup_mcp_json(&session.worktree_path);
320+
}
321+
317322
// 4. Resolve main repo path before worktree removal (needed for branch cleanup)
318323
let main_repo_path = git::removal::find_main_repo_root(&session.worktree_path);
319324

crates/kild-core/src/sessions/fleet.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,13 @@ pub fn fleet_mode_active(branch: &str) -> bool {
8989
///
9090
/// Returns None if fleet mode does not apply (wrong agent, not active, etc.).
9191
/// The returned string is appended to the existing agent command.
92-
pub fn fleet_agent_flags(branch: &str, agent: &str) -> Option<String> {
92+
pub fn fleet_agent_flags(branch: &str, agent: &str, channels_enabled: bool) -> Option<String> {
9393
if !is_claude_fleet_agent(agent) || !fleet_mode_active(branch) {
9494
return None;
9595
}
9696

9797
let safe_name = fleet_safe_name(branch);
98-
let flags = if branch == BRAIN_BRANCH {
98+
let mut flags = if branch == BRAIN_BRANCH {
9999
// Brain loads the kild-brain agent definition and joins as team lead.
100100
format!(
101101
"--agent kild-brain --agent-id {safe_name}@{TEAM_NAME} \
@@ -107,6 +107,10 @@ pub fn fleet_agent_flags(branch: &str, agent: &str) -> Option<String> {
107107
)
108108
};
109109

110+
if channels_enabled {
111+
flags.push_str(" --dangerously-load-development-channels server:kild-fleet");
112+
}
113+
110114
Some(flags)
111115
}
112116

@@ -604,7 +608,7 @@ mod tests {
604608
#[test]
605609
fn fleet_agent_flags_brain_gets_kild_brain_flag() {
606610
with_team_dir("brain_flag", |_| {
607-
let flags = fleet_agent_flags(BRAIN_BRANCH, "claude").unwrap();
611+
let flags = fleet_agent_flags(BRAIN_BRANCH, "claude", false).unwrap();
608612
assert!(
609613
flags.contains("--agent kild-brain"),
610614
"brain should get --agent kild-brain, got: {}",
@@ -621,7 +625,7 @@ mod tests {
621625
#[test]
622626
fn fleet_agent_flags_worker_does_not_get_brain_flag() {
623627
with_team_dir("worker_no_brain_flag", |_| {
624-
let flags = fleet_agent_flags("my-feature", "claude").unwrap();
628+
let flags = fleet_agent_flags("my-feature", "claude", false).unwrap();
625629
assert!(
626630
!flags.contains("--agent kild-brain"),
627631
"worker should not get --agent kild-brain, got: {}",
@@ -638,7 +642,7 @@ mod tests {
638642
#[test]
639643
fn fleet_agent_flags_slashed_branch_sanitized_in_agent_name() {
640644
with_team_dir("slashed_branch_flags", |_| {
641-
let flags = fleet_agent_flags("refactor/consolidate-ipc", "claude").unwrap();
645+
let flags = fleet_agent_flags("refactor/consolidate-ipc", "claude", false).unwrap();
642646
assert!(
643647
flags.contains("--agent-id refactor-consolidate-ipc@honryu"),
644648
"slashed branch should be sanitized in agent-id, got: {}",
@@ -660,18 +664,18 @@ mod tests {
660664
#[test]
661665
fn fleet_agent_flags_non_claude_returns_none() {
662666
with_team_dir("non_claude_none", |_| {
663-
assert!(fleet_agent_flags("my-feature", "amp").is_none());
664-
assert!(fleet_agent_flags("my-feature", "codex").is_none());
665-
assert!(fleet_agent_flags("my-feature", "kiro").is_none());
666-
assert!(fleet_agent_flags("my-feature", "gemini").is_none());
667+
assert!(fleet_agent_flags("my-feature", "amp", false).is_none());
668+
assert!(fleet_agent_flags("my-feature", "codex", false).is_none());
669+
assert!(fleet_agent_flags("my-feature", "kiro", false).is_none());
670+
assert!(fleet_agent_flags("my-feature", "gemini", false).is_none());
667671
});
668672
}
669673

670674
#[test]
671675
fn fleet_agent_flags_returns_none_when_no_team_dir_and_not_brain() {
672676
without_team_dir("no_dir_worker", |_| {
673677
assert!(
674-
fleet_agent_flags("my-feature", "claude").is_none(),
678+
fleet_agent_flags("my-feature", "claude", false).is_none(),
675679
"should be None when team dir absent and branch is not brain"
676680
);
677681
});
@@ -681,7 +685,7 @@ mod tests {
681685
fn fleet_agent_flags_brain_returns_flags_even_without_team_dir() {
682686
without_team_dir("no_dir_brain", |_| {
683687
// Brain creates the team — fleet activates unconditionally for the brain branch.
684-
let flags = fleet_agent_flags(BRAIN_BRANCH, "claude");
688+
let flags = fleet_agent_flags(BRAIN_BRANCH, "claude", false);
685689
assert!(
686690
flags.is_some(),
687691
"brain should get flags even when team dir absent"

0 commit comments

Comments
 (0)