From 30743863a5c722114b57bd86b16ebe4088fd2897 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:08:04 -0400 Subject: [PATCH 01/22] fix: int32 deserialize for MCP count fields and notify socket ref mut - Add custom option_i32/option_u8 serde deserializers so MCP count fields accept numbers sent as floats (e.g. 3.0) from Claude Code - Fix notify_rx ref mut in factory lifecycle to allow mutable drain/recv - Refactor DaemonNotifier socket to lazy tokio conversion (std socket bound before Tokio runtime, converted on first async use) - Use gcc linker instead of clang for local build --- .cargo/config.toml | 2 +- .../ui/factory/daemon/runtime/lifecycle.rs | 4 +- crates/cas-factory/src/notify.rs | 38 +++++-- crates/cas-mcp/src/types.rs | 5 +- crates/cas-mcp/src/types/deser.rs | 107 ++++++++++++++++++ crates/cas-mcp/src/types/ops_secondary.rs | 4 +- crates/cas-mcp/src/types_tests/tests.rs | 78 +++++++++++++ 7 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 crates/cas-mcp/src/types/deser.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index c5cc5d9f3..9c827ed64 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -8,5 +8,5 @@ jobs = 16 # on machines without sccache installed. [target.x86_64-unknown-linux-gnu] -linker = "clang" +linker = "gcc" rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs b/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs index c4de2c6ee..deff65c32 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs @@ -221,7 +221,7 @@ impl FactoryDaemon { // Poll prompt queue (on notification or timer) if prompt_notified || last_prompt_poll.elapsed() >= poll_interval { if prompt_notified { - if let Some(ref notify) = self.notify_rx { + if let Some(ref mut notify) = self.notify_rx { notify.drain(); } } @@ -478,7 +478,7 @@ impl FactoryDaemon { 16 }; let sleep_dur = Duration::from_millis(sleep_ms); - if let Some(ref notify) = self.notify_rx { + if let Some(ref mut notify) = self.notify_rx { tokio::select! { result = notify.recv() => { if result.is_ok() { diff --git a/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 4b1f30047..37876b552 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -19,7 +19,10 @@ pub fn notify_socket_path(cas_dir: &Path) -> PathBuf { /// Used in a `tokio::select!` branch to wake the event loop instantly when /// new prompts are enqueued. pub struct DaemonNotifier { - socket: UnixDatagram, + /// Bound std socket — converted to tokio lazily on first async use so that + /// `bind()` can be called before a Tokio runtime exists. + std_socket: Option, + socket: Option, path: PathBuf, } @@ -27,6 +30,7 @@ impl DaemonNotifier { /// Bind the notification socket at `{cas_dir}/notify.sock`. /// /// Removes a stale socket file from a previous run if one exists. + /// Safe to call before a Tokio runtime is active. pub fn bind(cas_dir: &Path) -> std::io::Result { let path = notify_socket_path(cas_dir); @@ -40,23 +44,43 @@ impl DaemonNotifier { std::fs::create_dir_all(parent)?; } - let socket = UnixDatagram::bind(&path)?; - Ok(Self { socket, path }) + let std_socket = StdUnixDatagram::bind(&path)?; + std_socket.set_nonblocking(true)?; + Ok(Self { + std_socket: Some(std_socket), + socket: None, + path, + }) + } + + /// Convert the std socket to a tokio socket. Must be called from within a + /// Tokio runtime. Idempotent — safe to call multiple times. + fn tokio_socket(&mut self) -> std::io::Result<&UnixDatagram> { + if self.socket.is_none() { + let std_sock = self + .std_socket + .take() + .expect("std_socket already consumed"); + self.socket = Some(UnixDatagram::from_std(std_sock)?); + } + Ok(self.socket.as_ref().unwrap()) } /// Async wait for a notification byte. Cancellation-safe (tokio /// `UnixDatagram::recv` is cancellation-safe). - pub async fn recv(&self) -> std::io::Result<()> { + pub async fn recv(&mut self) -> std::io::Result<()> { let mut buf = [0u8; 64]; - self.socket.recv(&mut buf).await?; + self.tokio_socket()?.recv(&mut buf).await?; Ok(()) } /// Non-blocking drain of all pending datagrams to coalesce multiple /// notifications into a single wakeup. - pub fn drain(&self) { + pub fn drain(&mut self) { let mut buf = [0u8; 64]; - while self.socket.try_recv(&mut buf).is_ok() {} + if let Ok(sock) = self.tokio_socket() { + while sock.try_recv(&mut buf).is_ok() {} + } } /// Remove the socket file (called on shutdown). diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index 3a5e5a78a..f8dddeedf 100644 --- a/crates/cas-mcp/src/types.rs +++ b/crates/cas-mcp/src/types.rs @@ -124,7 +124,7 @@ pub struct TaskRequest { /// Priority 0-4 (for create, update) #[schemars(description = "Priority: 0=Critical, 1=High, 2=Medium, 3=Low, 4=Backlog")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u8")] pub priority: Option, /// Task type (for create): task, bug, feature, epic, chore @@ -690,7 +690,7 @@ pub struct PatternRequest { /// Priority 0-3 (for create, update, team_create_suggestion) #[schemars(description = "Priority: 0=Critical, 1=High, 2=Medium (default), 3=Low")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u8")] pub priority: Option, /// Propagation mode (for create, update) @@ -746,6 +746,7 @@ pub struct PatternRequest { pub include_dismissed: Option, } +pub(crate) mod deser; mod ops_secondary; pub use crate::types::ops_secondary::{ diff --git a/crates/cas-mcp/src/types/deser.rs b/crates/cas-mcp/src/types/deser.rs new file mode 100644 index 000000000..a989d0ead --- /dev/null +++ b/crates/cas-mcp/src/types/deser.rs @@ -0,0 +1,107 @@ +//! Custom deserializers for flexible numeric type coercion. +//! +//! Some MCP client implementations (including Claude Code) serialize numeric +//! parameters as JSON strings (e.g., `"3"` instead of `3`). These helpers +//! accept both native JSON numbers and string-encoded numbers so tool calls +//! are not rejected due to type mismatch. + +use serde::{de, Deserializer}; +use std::fmt; + +/// Deserialize `Option` accepting both numeric and string-encoded values. +/// +/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, +/// `3` → `Some(3)`, `"3"` → `Some(3)`. +pub fn option_u8<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an integer 0-255, a string containing an integer, or null") + } + + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_some>(self, d: D2) -> Result { + d.deserialize_any(self) + } + fn visit_u64(self, v: u64) -> Result { + u8::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Unsigned(v), &"a u8 value (0-255)") + }) + } + fn visit_i64(self, v: i64) -> Result { + u8::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Signed(v), &"a u8 value (0-255)") + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::().map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding a u8 (0-255)") + }) + } + } + + deserializer.deserialize_option(V) +} + +/// Deserialize `Option` accepting both numeric and string-encoded values. +/// +/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, +/// `3` → `Some(3)`, `"3"` → `Some(3)`. +pub fn option_i32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an integer, a string containing an integer, or null") + } + + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_some>(self, d: D2) -> Result { + d.deserialize_any(self) + } + fn visit_u64(self, v: u64) -> Result { + i32::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Unsigned(v), &"an i32 value") + }) + } + fn visit_i64(self, v: i64) -> Result { + i32::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Signed(v), &"an i32 value") + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::().map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding an i32") + }) + } + } + + deserializer.deserialize_option(V) +} diff --git a/crates/cas-mcp/src/types/ops_secondary.rs b/crates/cas-mcp/src/types/ops_secondary.rs index bb23447c5..67ac42c37 100644 --- a/crates/cas-mcp/src/types/ops_secondary.rs +++ b/crates/cas-mcp/src/types/ops_secondary.rs @@ -361,7 +361,7 @@ pub struct FactoryRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default)] + #[serde(default, deserialize_with = "super::deser::option_i32")] pub count: Option, /// Specific worker names (comma-separated) @@ -584,7 +584,7 @@ pub struct CoordinationRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default)] + #[serde(default, deserialize_with = "super::deser::option_i32")] pub count: Option, /// Comma-separated worker names diff --git a/crates/cas-mcp/src/types_tests/tests.rs b/crates/cas-mcp/src/types_tests/tests.rs index 73a3888ec..ab1a1bd81 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -139,3 +139,81 @@ fn test_spec_request_supersede() { assert_eq!(req.supersedes_id, Some("spec-old456".to_string())); assert_eq!(req.new_version, Some(true)); } + +// ===== String-coercion tests (Claude Code serializes numbers as strings) ===== + +#[test] +fn test_task_request_priority_as_string() { + let req: TaskRequest = serde_json::from_str( + r#"{ + "action": "create", + "title": "Test", + "priority": "1" + }"#, + ) + .unwrap(); + assert_eq!(req.priority, Some(1)); +} + +#[test] +fn test_task_request_priority_null() { + let req: TaskRequest = serde_json::from_str( + r#"{ + "action": "list", + "priority": null + }"#, + ) + .unwrap(); + assert_eq!(req.priority, None); +} + +#[test] +fn test_task_request_priority_absent() { + let req: TaskRequest = serde_json::from_str(r#"{"action": "list"}"#).unwrap(); + assert_eq!(req.priority, None); +} + +#[test] +fn test_factory_request_count_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{ + "action": "spawn_workers", + "count": "3" + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(3)); +} + +#[test] +fn test_coordination_request_count_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "spawn_workers", + "count": "3", + "isolate": true + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(3)); +} + +#[test] +fn test_coordination_request_count_as_int() { + // Existing integer encoding must still work + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "shutdown_workers", + "count": 0 + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(0)); +} + +#[test] +fn test_coordination_request_count_null() { + let req: CoordinationRequest = + serde_json::from_str(r#"{"action": "worker_status", "count": null}"#).unwrap(); + assert_eq!(req.count, None); +} From 7dd1c1f6d7426aecb0967cf6fee32d5dc8305b7a Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:24:12 -0400 Subject: [PATCH 02/22] feat(config_gen): inject mcp__cas__* permissions into worker settings.json Workers were only getting Bash(cas :*) permissions in their generated settings.json, leaving mcp__cas__* tools unallowed. This required unreliable fallback to the global ~/.claude/settings.json. Add get_cas_mcp_permissions() alongside get_cas_bash_permissions() and merge both into the permissions.allow array in get_cas_hooks_config(). The 10 MCP tools added: task, coordination, memory, search, rule, skill, spec, verification, system, pattern. Co-Authored-By: Claude Sonnet 4.6 --- cas-cli/src/cli/hook/config_gen.rs | 23 +++++++++++++++++- cas-cli/src/cli/hook_tests/tests.rs | 37 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/cas-cli/src/cli/hook/config_gen.rs b/cas-cli/src/cli/hook/config_gen.rs index a59ff63e6..305ca3753 100644 --- a/cas-cli/src/cli/hook/config_gen.rs +++ b/cas-cli/src/cli/hook/config_gen.rs @@ -232,9 +232,12 @@ pub(crate) fn get_cas_hooks_config(config: &crate::config::HookConfig) -> serde_ ); } + let mut allow_permissions = get_cas_bash_permissions(); + allow_permissions.extend(get_cas_mcp_permissions()); + serde_json::json!({ "permissions": { - "allow": get_cas_bash_permissions() + "allow": allow_permissions }, "hooks": hooks, "statusLine": { @@ -257,6 +260,24 @@ pub fn get_cas_bash_permissions() -> Vec { ] } +/// Get MCP tool permission patterns for CAS tools +/// +/// Workers need these permissions to call mcp__cas__* tools without prompts. +pub fn get_cas_mcp_permissions() -> Vec { + vec![ + "mcp__cas__task".to_string(), + "mcp__cas__coordination".to_string(), + "mcp__cas__memory".to_string(), + "mcp__cas__search".to_string(), + "mcp__cas__rule".to_string(), + "mcp__cas__skill".to_string(), + "mcp__cas__spec".to_string(), + "mcp__cas__verification".to_string(), + "mcp__cas__system".to_string(), + "mcp__cas__pattern".to_string(), + ] +} + /// Configure CAS as an MCP server via .mcp.json /// /// Creates or updates .mcp.json in the project root to register CAS. diff --git a/cas-cli/src/cli/hook_tests/tests.rs b/cas-cli/src/cli/hook_tests/tests.rs index 3868751a3..b6c709900 100644 --- a/cas-cli/src/cli/hook_tests/tests.rs +++ b/cas-cli/src/cli/hook_tests/tests.rs @@ -31,6 +31,31 @@ fn test_configure_creates_settings() { allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")), "Bash(cas :*) permission missing" ); + // Verify CAS MCP tool permissions + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__task")), + "mcp__cas__task permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__coordination")), + "mcp__cas__coordination permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__memory")), + "mcp__cas__memory permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__search")), + "mcp__cas__search permission missing" + ); } #[test] @@ -91,6 +116,18 @@ fn test_configure_merges_existing() { allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")), "Bash(cas :*) permission should be added" ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__task")), + "mcp__cas__task permission should be added" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__coordination")), + "mcp__cas__coordination permission should be added" + ); } #[test] From 7356722523c0ba35ebe042780b8a58f281717a98 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:32:25 -0400 Subject: [PATCH 03/22] fix(factory): add .mcp.json committed-file pre-flight check for workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When workers use isolated git worktrees, .mcp.json must be committed to git or it won't appear in their checkout — leaving them with no MCP tools and silently idle. Changes: - preflight_factory_launch(): add git ls-files check for .mcp.json. Hard failure when workers > 0; advisory notice when supervisor-only. Quick-start steps show 'git add .mcp.json && git commit' when only .mcp.json is missing (not duplicated if .claude/ is also missing). - queue_codex_worker_intro_prompt(): inject MCP fallback instructions for Claude workers at startup. Tells workers to check .mcp.json, run 'cas init -y' to regenerate it, and notify supervisor via 'cas factory message' if MCP tools are unavailable — preventing silent idle loops. Closes cas-8397 (based on spike cas-4567). Co-Authored-By: Claude Sonnet 4.6 --- cas-cli/src/cli/factory/mod.rs | 32 +++++++++++++++++++++++++++++++ cas-cli/src/ui/factory/app/mod.rs | 27 +++++++++++++++++++++----- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cas-cli/src/cli/factory/mod.rs b/cas-cli/src/cli/factory/mod.rs index e6b2edee5..710237d51 100644 --- a/cas-cli/src/cli/factory/mod.rs +++ b/cas-cli/src/cli/factory/mod.rs @@ -826,6 +826,7 @@ fn preflight_factory_launch( let mut missing_git_repo = false; let mut missing_initial_commit = false; let mut missing_claude_commit = false; + let mut missing_mcp_commit = false; let resolved_cas_root = match validate_cas_root(cwd, cas_root) { Ok(path) => Some(path), @@ -961,6 +962,33 @@ fn preflight_factory_launch( } } + // Check if .mcp.json is committed (required for worktree-based workers) + if enable_worktrees && !missing_git_repo && !missing_initial_commit { + let mcp_tracked = std::process::Command::new("git") + .args(["ls-files", "--error-unmatch", ".mcp.json"]) + .current_dir(cwd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !mcp_tracked { + if args.workers > 0 { + failures.push( + ".mcp.json is not committed. Workers need it for MCP tool access in their worktrees." + .to_string(), + ); + missing_mcp_commit = true; + } else { + notices.push( + ".mcp.json is not committed. Commit it before spawning workers: git add .mcp.json && git commit -m \"Configure CAS MCP\"" + .to_string(), + ); + } + } + } + if !failures.is_empty() { let details = failures .iter() @@ -986,6 +1014,10 @@ fn preflight_factory_launch( steps.push("git add .claude/ CLAUDE.md .mcp.json .gitignore".to_string()); steps.push("git commit -m \"Configure CAS\"".to_string()); } + if missing_mcp_commit && !missing_claude_commit { + steps.push("git add .mcp.json".to_string()); + steps.push("git commit -m \"Configure CAS MCP\"".to_string()); + } let launch = if args.no_worktrees { "cas factory --no-worktrees" } else { diff --git a/cas-cli/src/ui/factory/app/mod.rs b/cas-cli/src/ui/factory/app/mod.rs index 66ca59ce1..32a985f29 100644 --- a/cas-cli/src/ui/factory/app/mod.rs +++ b/cas-cli/src/ui/factory/app/mod.rs @@ -786,11 +786,28 @@ pub(crate) fn queue_codex_worker_intro_prompt( worker_name: &str, worker_cli: cas_mux::SupervisorCli, ) { - let _ = cas_dir; - let _ = worker_name; - if worker_cli == cas_mux::SupervisorCli::Codex { - // Codex workers now receive startup workflow as the initial codex prompt arg at spawn time. - // Avoid queue injection here to prevent duplicate or draft-only startup prompts. + match worker_cli { + cas_mux::SupervisorCli::Codex => { + // Codex workers now receive startup workflow as the initial codex prompt arg at spawn time. + // Avoid queue injection here to prevent duplicate or draft-only startup prompts. + } + cas_mux::SupervisorCli::Claude => { + // Inject MCP fallback instructions so workers can self-diagnose and notify the + // supervisor if MCP tools fail to load (e.g. .mcp.json missing from worktree). + let prompt = format!( + "You are a CAS factory worker ({worker_name}).\n\ + Check your assigned tasks: `mcp__cas__task action=mine`\n\n\ + IMPORTANT — if mcp__cas__* tools are unavailable:\n\ + 1. Run: `cat .mcp.json` to verify MCP config exists in your worktree\n\ + 2. If missing, run: `cas init -y` to regenerate it (takes effect next session)\n\ + 3. Notify supervisor immediately via CLI fallback:\n\ + `cas factory message --target supervisor --message \"Worker {worker_name}: MCP tools unavailable. .mcp.json may be missing from worktree.\"`\n\ + Do not remain silently idle — always notify the supervisor if you cannot access MCP tools." + ); + if let Ok(queue) = open_prompt_queue_store(cas_dir) { + let _ = queue.enqueue("cas", worker_name, &prompt); + } + } } } From 119139507c96d9d1664d05a06a1449c2ae840d1c Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 08:33:21 -0400 Subject: [PATCH 04/22] fix(mcp): complete numeric string-coercion for all MCP Option fields Replace hand-written option_u8/option_i32 deserializers with a single option_numeric_deser! macro generating 6 variants (u8, i32, i64, u32, usize, u64). Apply to all ~30 Option fields across MCP request types so Claude Code's string-encoded numbers ("3") are accepted alongside native JSON numbers (3). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-mcp/src/types.rs | 22 +- crates/cas-mcp/src/types/deser.rs | 153 ++++------ crates/cas-mcp/src/types/ops_secondary.rs | 49 ++-- crates/cas-mcp/src/types_tests/tests.rs | 343 ++++++++++++++++++++++ 4 files changed, 442 insertions(+), 125 deletions(-) diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index f8dddeedf..fafec08db 100644 --- a/crates/cas-mcp/src/types.rs +++ b/crates/cas-mcp/src/types.rs @@ -60,7 +60,7 @@ pub struct MemoryRequest { /// Limit for list/recent #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -178,7 +178,7 @@ pub struct TaskRequest { /// Lease duration in seconds (for claim) #[schemars(description = "Lease duration in seconds (default: 600)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub duration_secs: Option, /// Target agent ID (for transfer) @@ -188,7 +188,7 @@ pub struct TaskRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -288,7 +288,7 @@ pub struct RuleRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -357,7 +357,7 @@ pub struct SkillRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -536,7 +536,7 @@ pub struct SpecRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, } @@ -586,7 +586,7 @@ pub struct AgentRequest { /// Max iterations (for loop_start, 0 = unlimited) #[schemars(description = "Maximum iterations (0 = unlimited)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u32")] pub max_iterations: Option, /// Completion promise (for loop_start) @@ -601,12 +601,12 @@ pub struct AgentRequest { /// Stale threshold seconds (for cleanup) #[schemars(description = "Seconds since last heartbeat to consider stale")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub stale_threshold_secs: Option, /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, // ========== Queue Operations Fields (Factory Mode) ========== @@ -636,7 +636,7 @@ pub struct AgentRequest { /// Notification ID (for queue_ack) #[schemars(description = "Notification ID to acknowledge")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub notification_id: Option, // ========== Message Queue Fields (Agent → Agent) ========== @@ -717,7 +717,7 @@ pub struct PatternRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Team ID (for team_* actions) diff --git a/crates/cas-mcp/src/types/deser.rs b/crates/cas-mcp/src/types/deser.rs index a989d0ead..e59dc913f 100644 --- a/crates/cas-mcp/src/types/deser.rs +++ b/crates/cas-mcp/src/types/deser.rs @@ -8,100 +8,73 @@ use serde::{de, Deserializer}; use std::fmt; -/// Deserialize `Option` accepting both numeric and string-encoded values. +/// Generate an `Option<$target>` deserializer that accepts numbers, strings, and null. /// -/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, -/// `3` → `Some(3)`, `"3"` → `Some(3)`. -pub fn option_u8<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct V; - impl<'de> de::Visitor<'de> for V { - type Value = Option; +/// Produces a public function `$fn_name` usable with `#[serde(deserialize_with = "...")]`. +macro_rules! option_numeric_deser { + ($fn_name:ident, $target:ty, $desc:expr) => { + pub fn $fn_name<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option<$target>; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an integer 0-255, a string containing an integer, or null") - } + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}, a string containing one, or null", $desc) + } - fn visit_none(self) -> Result { - Ok(None) - } - fn visit_unit(self) -> Result { - Ok(None) - } - fn visit_some>(self, d: D2) -> Result { - d.deserialize_any(self) - } - fn visit_u64(self, v: u64) -> Result { - u8::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Unsigned(v), &"a u8 value (0-255)") - }) - } - fn visit_i64(self, v: i64) -> Result { - u8::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Signed(v), &"a u8 value (0-255)") - }) - } - fn visit_str(self, v: &str) -> Result { - let t = v.trim(); - if t.is_empty() { - return Ok(None); + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_some>( + self, + d: D2, + ) -> Result { + d.deserialize_any(self) + } + fn visit_u64(self, v: u64) -> Result { + <$target>::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Unsigned(v), + &concat!("a ", stringify!($target), " value"), + ) + }) + } + fn visit_i64(self, v: i64) -> Result { + <$target>::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Signed(v), + &concat!("a ", stringify!($target), " value"), + ) + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::<$target>().map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Str(v), + &concat!("a string encoding a ", stringify!($target)), + ) + }) + } } - t.parse::().map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding a u8 (0-255)") - }) - } - } - - deserializer.deserialize_option(V) -} - -/// Deserialize `Option` accepting both numeric and string-encoded values. -/// -/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, -/// `3` → `Some(3)`, `"3"` → `Some(3)`. -pub fn option_i32<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct V; - impl<'de> de::Visitor<'de> for V { - type Value = Option; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an integer, a string containing an integer, or null") + deserializer.deserialize_option(V) } - - fn visit_none(self) -> Result { - Ok(None) - } - fn visit_unit(self) -> Result { - Ok(None) - } - fn visit_some>(self, d: D2) -> Result { - d.deserialize_any(self) - } - fn visit_u64(self, v: u64) -> Result { - i32::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Unsigned(v), &"an i32 value") - }) - } - fn visit_i64(self, v: i64) -> Result { - i32::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Signed(v), &"an i32 value") - }) - } - fn visit_str(self, v: &str) -> Result { - let t = v.trim(); - if t.is_empty() { - return Ok(None); - } - t.parse::().map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding an i32") - }) - } - } - - deserializer.deserialize_option(V) + }; } + +option_numeric_deser!(option_u8, u8, "an integer 0-255"); +option_numeric_deser!(option_i32, i32, "an i32 integer"); +option_numeric_deser!(option_i64, i64, "an i64 integer"); +option_numeric_deser!(option_u32, u32, "a u32 integer"); +option_numeric_deser!(option_usize, usize, "a usize integer"); +option_numeric_deser!(option_u64, u64, "a u64 integer"); diff --git a/crates/cas-mcp/src/types/ops_secondary.rs b/crates/cas-mcp/src/types/ops_secondary.rs index 67ac42c37..c00a6712a 100644 --- a/crates/cas-mcp/src/types/ops_secondary.rs +++ b/crates/cas-mcp/src/types/ops_secondary.rs @@ -1,5 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::deser; /// Unified search, context, and entity operations request #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -29,7 +30,7 @@ pub struct SearchContextRequest { /// Max tokens for context #[schemars(description = "Maximum tokens for context")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub max_tokens: Option, /// Include related memories @@ -78,7 +79,7 @@ pub struct SearchContextRequest { /// Limit for list/search #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Sort field (for search) @@ -121,12 +122,12 @@ pub struct SearchContextRequest { /// Lines of context before match (for grep) #[schemars(description = "Lines of context before each match (grep -B)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub before_context: Option, /// Lines of context after match (for grep) #[schemars(description = "Lines of context after each match (grep -A)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub after_context: Option, /// Case insensitive search (for grep) @@ -142,12 +143,12 @@ pub struct SearchContextRequest { /// Start line for blame range #[schemars(description = "Start line number for blame range")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub line_start: Option, /// End line for blame range #[schemars(description = "End line number for blame range")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub line_end: Option, /// Filter to only AI-generated lines (for blame) @@ -316,12 +317,12 @@ pub struct VerificationRequest { /// Duration of verification in milliseconds (for add) #[schemars(description = "Duration in milliseconds")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u64")] pub duration_ms: Option, /// Limit for list #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Verification type: 'task' (default) or 'epic' @@ -344,7 +345,7 @@ pub struct TeamRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, } @@ -361,7 +362,7 @@ pub struct FactoryRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default, deserialize_with = "super::deser::option_i32")] + #[serde(default, deserialize_with = "deser::option_i32")] pub count: Option, /// Specific worker names (comma-separated) @@ -397,7 +398,7 @@ pub struct FactoryRequest { /// Threshold used by cleanup/report actions (seconds) #[schemars(description = "Optional threshold in seconds for cleanup/report actions")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub older_than_secs: Option, /// Whether spawned workers need isolated worktrees (git worktree per worker) @@ -414,7 +415,7 @@ pub struct FactoryRequest { /// Delay in seconds before reminder fires (time-based trigger) #[schemars(description = "Delay in seconds before reminder fires (time-based trigger)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_delay_secs: Option, /// Event type that triggers the reminder (event-based trigger) @@ -433,14 +434,14 @@ pub struct FactoryRequest { /// Reminder ID for cancel operations #[schemars(description = "Reminder ID for cancel operations")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_id: Option, /// TTL in seconds for the reminder (default: 3600) #[schemars( description = "Time-to-live in seconds for the reminder before auto-expiry (default: 3600)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_ttl_secs: Option, } @@ -501,7 +502,7 @@ pub struct CoordinationRequest { /// Maximum items to return #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, // ========== Agent Fields ========== @@ -532,7 +533,7 @@ pub struct CoordinationRequest { /// Max iterations (for loop_start, 0 = unlimited) #[schemars(description = "Maximum iterations (0 = unlimited)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u32")] pub max_iterations: Option, /// Completion promise (for loop_start) @@ -547,7 +548,7 @@ pub struct CoordinationRequest { /// Stale threshold seconds (for agent_cleanup) #[schemars(description = "Seconds since last heartbeat to consider stale")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub stale_threshold_secs: Option, /// Supervisor ID (for queue operations) @@ -576,7 +577,7 @@ pub struct CoordinationRequest { /// Notification ID (for queue_ack) #[schemars(description = "Notification ID to acknowledge")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub notification_id: Option, // ========== Factory Fields ========== @@ -584,7 +585,7 @@ pub struct CoordinationRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default, deserialize_with = "super::deser::option_i32")] + #[serde(default, deserialize_with = "deser::option_i32")] pub count: Option, /// Comma-separated worker names @@ -601,7 +602,7 @@ pub struct CoordinationRequest { /// Threshold in seconds for cleanup/report actions #[schemars(description = "Optional threshold in seconds for cleanup/report actions")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub older_than_secs: Option, /// Whether workers need isolated git worktrees @@ -618,7 +619,7 @@ pub struct CoordinationRequest { /// Delay in seconds before reminder fires (time-based trigger) #[schemars(description = "Delay in seconds before reminder fires (time-based trigger)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_delay_secs: Option, /// Event type that triggers reminder @@ -637,14 +638,14 @@ pub struct CoordinationRequest { /// Reminder ID for cancel operations #[schemars(description = "Reminder ID for cancel operations")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_id: Option, /// Time-to-live in seconds for the reminder (default: 3600) #[schemars( description = "Time-to-live in seconds for the reminder before auto-expiry (default: 3600)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_ttl_secs: Option, // ========== Worktree Fields ========== @@ -690,7 +691,7 @@ pub struct ExecuteRequest { #[schemars( description = "Max response length in characters. Default: 40000. Use your code to extract only what you need rather than increasing this." )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub max_length: Option, } diff --git a/crates/cas-mcp/src/types_tests/tests.rs b/crates/cas-mcp/src/types_tests/tests.rs index ab1a1bd81..36cfda020 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -217,3 +217,346 @@ fn test_coordination_request_count_null() { serde_json::from_str(r#"{"action": "worker_status", "count": null}"#).unwrap(); assert_eq!(req.count, None); } + +// ===== option_i64 tests ===== + +#[test] +fn test_task_duration_secs_as_string() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "claim", "id": "t1", "duration_secs": "900"}"#, + ) + .unwrap(); + assert_eq!(req.duration_secs, Some(900)); +} + +#[test] +fn test_task_duration_secs_as_int() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "claim", "id": "t1", "duration_secs": 600}"#, + ) + .unwrap(); + assert_eq!(req.duration_secs, Some(600)); +} + +#[test] +fn test_task_duration_secs_null() { + let req: TaskRequest = + serde_json::from_str(r#"{"action": "claim", "duration_secs": null}"#).unwrap(); + assert_eq!(req.duration_secs, None); +} + +#[test] +fn test_task_duration_secs_absent() { + let req: TaskRequest = serde_json::from_str(r#"{"action": "claim"}"#).unwrap(); + assert_eq!(req.duration_secs, None); +} + +#[test] +fn test_agent_stale_threshold_as_string() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "cleanup", "stale_threshold_secs": "3600"}"#, + ) + .unwrap(); + assert_eq!(req.stale_threshold_secs, Some(3600)); +} + +#[test] +fn test_coordination_notification_id_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{"action": "queue_ack", "notification_id": "42"}"#, + ) + .unwrap(); + assert_eq!(req.notification_id, Some(42)); +} + +#[test] +fn test_factory_older_than_secs_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{"action": "gc_cleanup", "older_than_secs": "7200"}"#, + ) + .unwrap(); + assert_eq!(req.older_than_secs, Some(7200)); +} + +#[test] +fn test_factory_remind_fields_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{ + "action": "remind", + "remind_delay_secs": "120", + "remind_ttl_secs": "3600", + "remind_id": "7" + }"#, + ) + .unwrap(); + assert_eq!(req.remind_delay_secs, Some(120)); + assert_eq!(req.remind_ttl_secs, Some(3600)); + assert_eq!(req.remind_id, Some(7)); +} + +// ===== option_u32 tests ===== + +#[test] +fn test_agent_max_iterations_as_string() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": "10"}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(10)); +} + +#[test] +fn test_agent_max_iterations_as_int() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": 5}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(5)); +} + +#[test] +fn test_agent_max_iterations_null() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": null}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, None); +} + +#[test] +fn test_agent_max_iterations_absent() { + let req: AgentRequest = serde_json::from_str(r#"{"action": "loop_start"}"#).unwrap(); + assert_eq!(req.max_iterations, None); +} + +#[test] +fn test_coordination_max_iterations_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": "20"}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(20)); +} + +// ===== option_usize tests ===== + +#[test] +fn test_memory_limit_as_string() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": "50"}"#).unwrap(); + assert_eq!(req.limit, Some(50)); +} + +#[test] +fn test_memory_limit_as_int() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": 25}"#).unwrap(); + assert_eq!(req.limit, Some(25)); +} + +#[test] +fn test_memory_limit_null() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": null}"#).unwrap(); + assert_eq!(req.limit, None); +} + +#[test] +fn test_memory_limit_absent() { + let req: MemoryRequest = serde_json::from_str(r#"{"action": "list"}"#).unwrap(); + assert_eq!(req.limit, None); +} + +#[test] +fn test_task_limit_as_string() { + let req: TaskRequest = + serde_json::from_str(r#"{"action": "list", "limit": "100"}"#).unwrap(); + assert_eq!(req.limit, Some(100)); +} + +#[test] +fn test_rule_limit_as_string() { + let req: RuleRequest = + serde_json::from_str(r#"{"action": "list", "limit": "10"}"#).unwrap(); + assert_eq!(req.limit, Some(10)); +} + +#[test] +fn test_skill_limit_as_string() { + let req: SkillRequest = + serde_json::from_str(r#"{"action": "list", "limit": "15"}"#).unwrap(); + assert_eq!(req.limit, Some(15)); +} + +#[test] +fn test_spec_limit_as_string() { + let req: SpecRequest = + serde_json::from_str(r#"{"action": "list", "limit": "20"}"#).unwrap(); + assert_eq!(req.limit, Some(20)); +} + +#[test] +fn test_search_max_tokens_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "context", "max_tokens": "4096"}"#, + ) + .unwrap(); + assert_eq!(req.max_tokens, Some(4096)); +} + +#[test] +fn test_search_context_lines_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "grep", "pattern": "foo", "before_context": "3", "after_context": "5"}"#, + ) + .unwrap(); + assert_eq!(req.before_context, Some(3)); + assert_eq!(req.after_context, Some(5)); +} + +#[test] +fn test_search_line_range_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "blame", "file_path": "src/main.rs", "line_start": "10", "line_end": "20"}"#, + ) + .unwrap(); + assert_eq!(req.line_start, Some(10)); + assert_eq!(req.line_end, Some(20)); +} + +#[test] +fn test_search_limit_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "search", "query": "test", "limit": "30"}"#, + ) + .unwrap(); + assert_eq!(req.limit, Some(30)); +} + +#[test] +fn test_team_limit_as_string() { + let req: TeamRequest = + serde_json::from_str(r#"{"action": "list", "limit": "5"}"#).unwrap(); + assert_eq!(req.limit, Some(5)); +} + +#[test] +fn test_pattern_limit_as_string() { + let req: PatternRequest = + serde_json::from_str(r#"{"action": "list", "limit": "8"}"#).unwrap(); + assert_eq!(req.limit, Some(8)); +} + +#[test] +fn test_coordination_limit_as_string() { + let req: CoordinationRequest = + serde_json::from_str(r#"{"action": "agent_list", "limit": "50"}"#).unwrap(); + assert_eq!(req.limit, Some(50)); +} + +// ===== option_u64 tests ===== + +#[test] +fn test_verification_duration_ms_as_string() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": "1500"}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, Some(1500)); +} + +#[test] +fn test_verification_duration_ms_as_int() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": 2000}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, Some(2000)); +} + +#[test] +fn test_verification_duration_ms_null() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": null}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, None); +} + +#[test] +fn test_verification_duration_ms_absent() { + let req: VerificationRequest = + serde_json::from_str(r#"{"action": "add", "task_id": "t1"}"#).unwrap(); + assert_eq!(req.duration_ms, None); +} + +#[test] +fn test_verification_limit_as_string() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "list", "task_id": "t1", "limit": "10"}"#, + ) + .unwrap(); + assert_eq!(req.limit, Some(10)); +} + +// ===== ExecuteRequest max_length ===== + +#[test] +fn test_execute_max_length_as_string() { + let req: ExecuteRequest = serde_json::from_str( + r#"{"code": "return 1;", "max_length": "5000"}"#, + ) + .unwrap(); + assert_eq!(req.max_length, Some(5000)); +} + +#[test] +fn test_execute_max_length_as_int() { + let req: ExecuteRequest = serde_json::from_str( + r#"{"code": "return 1;", "max_length": 10000}"#, + ) + .unwrap(); + assert_eq!(req.max_length, Some(10000)); +} + +// ===== Empty string coercion to None ===== + +#[test] +fn test_empty_string_coerces_to_none() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "list", "priority": "", "limit": ""}"#, + ) + .unwrap(); + assert_eq!(req.priority, None); + assert_eq!(req.limit, None); +} + +// ===== Coordination request fields from factory/agent ===== + +#[test] +fn test_coordination_all_numeric_fields_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "remind", + "count": "2", + "max_iterations": "10", + "stale_threshold_secs": "300", + "notification_id": "99", + "older_than_secs": "7200", + "remind_delay_secs": "60", + "remind_id": "5", + "remind_ttl_secs": "1800", + "limit": "25" + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(2)); + assert_eq!(req.max_iterations, Some(10)); + assert_eq!(req.stale_threshold_secs, Some(300)); + assert_eq!(req.notification_id, Some(99)); + assert_eq!(req.older_than_secs, Some(7200)); + assert_eq!(req.remind_delay_secs, Some(60)); + assert_eq!(req.remind_id, Some(5)); + assert_eq!(req.remind_ttl_secs, Some(1800)); + assert_eq!(req.limit, Some(25)); +} From 4d2078e763d746ef34eaf94c5c5bbcb247ddb1a9 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:37:06 -0400 Subject: [PATCH 05/22] fix(tui): OSC 52 clipboard copy and auto-inject on image paste - handle_mouse_up() returns selected text instead of calling arboard directly (daemon is headless, can't access system clipboard) - Daemon relays selected text to client terminal via OSC 52 escape sequence for native clipboard write - Auto-enter inject mode after image drop so user can immediately type context for pasted images Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/factory/app/sidecar_and_selection.rs | 21 ++++++------- .../ui/factory/daemon/runtime/client_input.rs | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cas-cli/src/ui/factory/app/sidecar_and_selection.rs b/cas-cli/src/ui/factory/app/sidecar_and_selection.rs index 96d7827dc..d6d1eb02d 100644 --- a/cas-cli/src/ui/factory/app/sidecar_and_selection.rs +++ b/cas-cli/src/ui/factory/app/sidecar_and_selection.rs @@ -352,26 +352,25 @@ impl FactoryApp { } } - /// Handle mouse up - finalize selection and copy to clipboard - pub fn handle_mouse_up(&mut self) { + /// Handle mouse up - finalize selection and return selected text for clipboard. + /// + /// Returns the selected text (if any) so the caller can relay it to the + /// client terminal via OSC 52. The daemon process is headless and cannot + /// write to the system clipboard directly. + pub fn handle_mouse_up(&mut self) -> Option { // Finalize the selection if self.selection.is_active { self.selection.finalize(); } - // Copy to clipboard if selection exists + // Return selected text for the caller to handle clipboard if let Some(text) = self.get_selected_text() { if !text.is_empty() { - match crate::ui::factory::clipboard::copy_to_clipboard(&text) { - Ok(()) => { - tracing::debug!("Copied {} chars to clipboard", text.len()); - } - Err(e) => { - tracing::warn!("Failed to copy to clipboard: {}", e); - } - } + tracing::debug!("Selection complete: {} chars", text.len()); + return Some(text); } } + None } /// Start a text selection at the given screen position diff --git a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs index 276fa4bcf..67de7bb8e 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs @@ -182,7 +182,18 @@ impl FactoryDaemon { || self.app.show_changes_dialog || self.app.show_help) { - self.app.handle_mouse_up(); + if let Some(text) = self.app.handle_mouse_up() { + // Send OSC 52 to the owner client so its + // terminal writes the text to the system + // clipboard. The daemon is headless and + // cannot access the clipboard directly. + let osc52 = osc52_copy_sequence(&text); + if let Some(client) = + self.clients.get_mut(&client_id) + { + client.output_buf.extend(osc52.as_bytes()); + } + } } } ControlEvent::MouseScrollUp => { @@ -213,6 +224,11 @@ impl FactoryDaemon { e ); } + // Auto-enter inject mode so the user can + // immediately type context for the image. + self.app.inject_target = Some(target_pane); + self.app.inject_buffer.clear(); + self.app.input_mode = crate::ui::factory::input::InputMode::Inject; } else { tracing::debug!( "Ignoring image drop outside worker/supervisor panes at ({}, {})", @@ -843,6 +859,19 @@ impl FactoryDaemon { } } +/// Build an OSC 52 escape sequence that tells the terminal to copy `text` +/// to the system clipboard. +/// +/// Format: `ESC ] 52 ; c ; ST` +/// where ST (String Terminator) = `ESC \` +/// +/// Supported by kitty, alacritty, wezterm, ghostty, iTerm2, and most modern +/// terminal emulators. +fn osc52_copy_sequence(text: &str) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + format!("\x1b]52;c;{}\x1b\\", encoded) +} + fn bracketed_paste_bytes(payload: &str) -> Vec { let mut out = Vec::with_capacity(payload.len() + 12); out.extend_from_slice(b"\x1b[200~"); From 7ee61e2391466167ab0f1fd84996de4b3dc505cd Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:41:21 -0400 Subject: [PATCH 06/22] feat(theme): add opt-in Minions theme for factory workers and boot screen Adds a "minions" theme variant selectable via `[theme] variant = "minions"` in config.toml. When active: workers get minion names (kevin, stuart, bob), supervisor is named gru/dru/nefario, the color palette shifts to yellow primary with denim blue accents, and the boot screen shows a minion ASCII art logo with "BANANA!" ready message and "Bee-do Bee-do" launch animation. Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/cli/factory/daemon.rs | 9 + cas-cli/src/cli/factory/mod.rs | 25 ++- cas-cli/src/orchestration/mod.rs | 2 +- cas-cli/src/orchestration/names.rs | 103 ++++++++++ cas-cli/src/ui/factory/app/init.rs | 25 ++- cas-cli/src/ui/factory/boot.rs | 4 +- cas-cli/src/ui/factory/boot/screen.rs | 264 +++++++++++++++++++++----- cas-cli/src/ui/theme/colors.rs | 24 +++ cas-cli/src/ui/theme/config.rs | 57 +++++- cas-cli/src/ui/theme/icons.rs | 14 ++ cas-cli/src/ui/theme/mod.rs | 4 +- crates/cas-factory/src/config.rs | 3 + 12 files changed, 467 insertions(+), 67 deletions(-) diff --git a/cas-cli/src/cli/factory/daemon.rs b/cas-cli/src/cli/factory/daemon.rs index 2eee7ed75..3e8f9e363 100644 --- a/cas-cli/src/cli/factory/daemon.rs +++ b/cas-cli/src/cli/factory/daemon.rs @@ -69,6 +69,11 @@ pub(super) fn execute_daemon( }, teams_configs, lead_session_id: Some(lead_session_id), + minions_theme: cas_config + .theme + .as_ref() + .map(|t| t.variant == crate::ui::theme::ThemeVariant::Minions) + .unwrap_or(false), }; let daemon_config = DaemonConfig { @@ -127,6 +132,7 @@ pub(super) fn run_factory_with_daemon( .unwrap_or_else(|| "supervisor".to_string()); let worker_names = config.worker_names.clone(); let worktrees_enabled = config.enable_worktrees; + let minions_theme = config.minions_theme; let cwd = config.cwd.to_string_lossy().to_string(); let profile = build_boot_profile(&config, worker_names.len()); @@ -148,6 +154,7 @@ pub(super) fn run_factory_with_daemon( session_name: session_name.clone(), profile, skip_animation: false, + minions_theme, }; if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, 0) { @@ -177,6 +184,7 @@ pub(super) fn run_factory_with_daemon( .unwrap_or_else(|| "supervisor".to_string()); let worker_names = config.worker_names.clone(); let worktrees_enabled = config.enable_worktrees; + let minions_theme = config.minions_theme; let cwd = config.cwd.to_string_lossy().to_string(); let profile = build_boot_profile(&config, worker_names.len()); @@ -199,6 +207,7 @@ pub(super) fn run_factory_with_daemon( session_name: session_name.clone(), profile, skip_animation: false, + minions_theme, }; if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, daemon_pid) { diff --git a/cas-cli/src/cli/factory/mod.rs b/cas-cli/src/cli/factory/mod.rs index 710237d51..4268f1365 100644 --- a/cas-cli/src/cli/factory/mod.rs +++ b/cas-cli/src/cli/factory/mod.rs @@ -549,9 +549,27 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path> } } - let all_names = generate_unique(args.workers as usize + 1); - let supervisor_name = all_names[0].clone(); - let worker_names: Vec = all_names[1..].to_vec(); + // Determine theme variant early so we can use themed names + let theme_variant = { + let cd = cwd.join(".cas"); + let cr = cas_root.or_else(|| if cd.exists() { Some(cd.as_path()) } else { None }); + cr.and_then(|r| Config::load(r).ok()) + .and_then(|c| c.theme.as_ref().map(|t| t.variant)) + .unwrap_or_default() + }; + let is_minions = theme_variant == crate::ui::theme::ThemeVariant::Minions; + + let (supervisor_name, worker_names) = if is_minions { + use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique}; + let sup = generate_minion_supervisor(); + let workers = generate_minion_unique(args.workers as usize); + (sup, workers) + } else { + let all_names = generate_unique(args.workers as usize + 1); + let sup = all_names[0].clone(); + let workers: Vec = all_names[1..].to_vec(); + (sup, workers) + }; let session_name = args .name @@ -613,6 +631,7 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path> }, teams_configs, lead_session_id: Some(lead_session_id), + minions_theme: is_minions, }; let phone_home = !args.no_phone_home; diff --git a/cas-cli/src/orchestration/mod.rs b/cas-cli/src/orchestration/mod.rs index 636dddb4d..464cde003 100644 --- a/cas-cli/src/orchestration/mod.rs +++ b/cas-cli/src/orchestration/mod.rs @@ -7,4 +7,4 @@ pub mod names; -pub use names::generate_unique; +pub use names::{generate_minion_supervisor, generate_minion_unique, generate_unique}; diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 6816e83f7..046ec66a2 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -7,6 +7,69 @@ use rand::Rng; use rand::seq::IndexedRandom; use std::collections::HashSet; +// ── Minions theme names ────────────────────────────────────────────────────── + +const MINION_WORKERS: &[&str] = &[ + "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", + "norbert", "larry", "tom", "chris", "john", "paul", "mike", "ken", "mel", + "lance", "donny", +]; + +const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; + +/// Generate a single minion worker name (e.g., "kevin", "stuart") +pub fn generate_minion() -> String { + let mut rng = rand::rng(); + let name = MINION_WORKERS.choose(&mut rng).unwrap_or(&"bob"); + (*name).to_string() +} + +/// Generate a minion supervisor name +pub fn generate_minion_supervisor() -> String { + let mut rng = rand::rng(); + let name = MINION_SUPERVISORS.choose(&mut rng).unwrap_or(&"gru"); + (*name).to_string() +} + +/// Generate N unique minion worker names. +/// +/// If more names are requested than available, appends a numeric suffix. +pub fn generate_minion_unique(count: usize) -> Vec { + let mut names = Vec::with_capacity(count); + let mut rng = rand::rng(); + + // Shuffle the pool and take as many as we can + let mut pool: Vec<&str> = MINION_WORKERS.to_vec(); + // Fisher-Yates shuffle + for i in (1..pool.len()).rev() { + let j = rng.random_range(0..=i); + pool.swap(i, j); + } + + for (i, name) in pool.iter().enumerate() { + if i >= count { + break; + } + names.push((*name).to_string()); + } + + // If we need more than the pool, add suffixed duplicates + let mut suffix = 2; + while names.len() < count { + for name in &pool { + if names.len() >= count { + break; + } + names.push(format!("{name}-{suffix}")); + } + suffix += 1; + } + + names +} + +// ── Default theme names ────────────────────────────────────────────────────── + const ADJECTIVES: &[&str] = &[ "agile", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp", "daring", "eager", "fair", "fast", "fierce", "gentle", "golden", "happy", "jolly", "keen", "kind", "lively", @@ -123,4 +186,44 @@ mod tests { let names = generate_unique(100); assert_eq!(names.len(), 100); } + + #[test] + fn test_generate_minion_returns_valid_name() { + let name = generate_minion(); + assert!( + MINION_WORKERS.contains(&name.as_str()), + "Minion name should be valid: {name}" + ); + } + + #[test] + fn test_generate_minion_supervisor_returns_valid_name() { + let name = generate_minion_supervisor(); + assert!( + MINION_SUPERVISORS.contains(&name.as_str()), + "Supervisor name should be valid: {name}" + ); + } + + #[test] + fn test_generate_minion_unique_returns_correct_count() { + let names = generate_minion_unique(5); + assert_eq!(names.len(), 5); + } + + #[test] + fn test_generate_minion_unique_all_different() { + let names = generate_minion_unique(10); + let unique: HashSet<_> = names.iter().collect(); + assert_eq!(unique.len(), names.len(), "All minion names should be unique"); + } + + #[test] + fn test_generate_minion_unique_exceeds_pool() { + // More than 20 minion names, should use suffixes + let names = generate_minion_unique(25); + assert_eq!(names.len(), 25); + let unique: HashSet<_> = names.iter().collect(); + assert_eq!(unique.len(), 25, "All names should still be unique"); + } } diff --git a/cas-cli/src/ui/factory/app/init.rs b/cas-cli/src/ui/factory/app/init.rs index d5a294e66..0ee0cae09 100644 --- a/cas-cli/src/ui/factory/app/init.rs +++ b/cas-cli/src/ui/factory/app/init.rs @@ -29,14 +29,25 @@ impl FactoryApp { let cas_dir = find_cas_root()?; - let all_names = generate_unique(config.workers + 1); - let supervisor_name = config - .supervisor_name - .unwrap_or_else(|| all_names[0].clone()); - let worker_names: Vec = if config.worker_names.is_empty() { - all_names[1..].to_vec() + let (supervisor_name, worker_names) = if config.minions_theme + && config.supervisor_name.is_none() + && config.worker_names.is_empty() + { + use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique}; + let sup = generate_minion_supervisor(); + let workers = generate_minion_unique(config.workers); + (sup, workers) } else { - config.worker_names + let all_names = generate_unique(config.workers + 1); + let sup = config + .supervisor_name + .unwrap_or_else(|| all_names[0].clone()); + let workers = if config.worker_names.is_empty() { + all_names[1..].to_vec() + } else { + config.worker_names + }; + (sup, workers) }; let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40)); diff --git a/cas-cli/src/ui/factory/boot.rs b/cas-cli/src/ui/factory/boot.rs index 23099e201..dd20b2a1b 100644 --- a/cas-cli/src/ui/factory/boot.rs +++ b/cas-cli/src/ui/factory/boot.rs @@ -17,6 +17,8 @@ pub struct BootConfig { pub profile: String, /// Skip animations (for testing) pub skip_animation: bool, + /// Use minions theme + pub minions_theme: bool, } mod screen; @@ -36,7 +38,7 @@ pub fn run_boot_screen_client( use std::collections::HashMap as AgentMap; use std::io::Read; - let mut screen = BootScreen::new(boot_config.skip_animation)?; + let mut screen = BootScreen::new_themed(boot_config.skip_animation, boot_config.minions_theme)?; // Draw logo and get starting row let box_start = screen.draw_logo()?; diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 707cee963..02061bb33 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -93,6 +93,91 @@ mod colors { }; } +/// Minions-themed colors for the boot screen +mod minions_colors { + use crossterm::style::Color; + + // Logo colors - Minion yellow with glow + pub const LOGO: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const LOGO_GLOW: Color = Color::Rgb { + r: 255, + g: 235, + b: 100, + }; + + // Text colors + pub const HEADER: Color = Color::White; + pub const LABEL: Color = Color::Rgb { + r: 120, + g: 120, + b: 130, + }; + pub const VALUE: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + + // Status colors (keep functional) + pub const OK: Color = Color::Rgb { + r: 80, + g: 250, + b: 120, + }; + pub const PENDING: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const ERROR: Color = Color::Rgb { + r: 255, + g: 90, + b: 90, + }; + + // Progress bar - yellow fill + pub const PROGRESS_DONE: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const PROGRESS_EMPTY: Color = Color::Rgb { + r: 50, + g: 50, + b: 55, + }; + + // Agent role colors - denim blue for workers, dark for supervisor (Gru) + pub const WORKER: Color = Color::Rgb { + r: 65, + g: 105, + b: 225, + }; + pub const SUPERVISOR: Color = Color::Rgb { + r: 80, + g: 80, + b: 85, + }; + + // Box/frame colors - denim blue tint + pub const BOX: Color = Color::Rgb { + r: 50, + g: 60, + b: 90, + }; + + // Final ready state - banana yellow + pub const READY: Color = Color::Rgb { + r: 255, + g: 235, + b: 59, + }; +} + /// ASCII art logo for CAS Factory const LOGO: &str = r#" ██████╗ █████╗ ███████╗ ███████╗ █████╗ ██████╗████████╗ ██████╗ ██████╗ ██╗ ██╗ @@ -114,6 +199,26 @@ const LOGO_SMALL: &str = r#" ╚═══════════════════════════════════════════════════════╝ "#; +/// Minion ASCII art logo +const MINION_LOGO: &str = r#" + ╭──────────╮ + ╭┤ ╭────╮ ├╮ + │╰──┤ ●● ├──╯│ + │ ╰────╯ │ + │ ╭────╮ │ + │ │ │ │ + ╰───┤ ├───╯ + ╰────╯ +"#; + +/// Smaller minion for narrow/short terminals +const MINION_LOGO_SMALL: &str = r#" + ╭──────╮ + │ (●●) │ + │ ╭──╮ │ + ╰─┤ ├─╯ +"#; + /// Braille spinner frames for smooth animation const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -131,11 +236,23 @@ pub(crate) struct BootScreen { pub(crate) steps_row: u16, pub(crate) agent_row: u16, pub(crate) skip_animation: bool, + pub(crate) minions_theme: bool, spinner_tick: usize, } +/// Helper to select default or minions color +macro_rules! themed { + ($self:expr, $name:ident) => { + if $self.minions_theme { + minions_colors::$name + } else { + colors::$name + } + }; +} + impl BootScreen { - pub(crate) fn new(skip_animation: bool) -> std::io::Result { + pub(crate) fn new_themed(skip_animation: bool, minions_theme: bool) -> std::io::Result { let mut stdout = stdout(); let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); @@ -153,29 +270,59 @@ impl BootScreen { steps_row: 0, // Set after logo agent_row: 0, // Set after steps skip_animation, + minions_theme, spinner_tick: 0, }) } pub(crate) fn draw_logo(&mut self) -> std::io::Result { + let logo_color = themed!(self, LOGO); + let logo_glow = themed!(self, LOGO_GLOW); + let header_color = themed!(self, HEADER); + let label_color = themed!(self, LABEL); + + let (title, subtitle) = if self.minions_theme { + ( + "═══ BANANA! ═══", + format!("Bee-do Bee-do • v{}", APP_VERSION), + ) + } else { + ( + "═══ Coding Agent System ═══", + format!("Multi-Agent Orchestration • v{}", APP_VERSION), + ) + }; + + let compact_title = if self.minions_theme { + "Minion Factory Boot" + } else { + "CAS Factory Boot" + }; + + let compact_subtitle = if self.minions_theme { + format!("Bee-do Bee-do • v{}", APP_VERSION) + } else { + format!("Coding Agent System • v{}", APP_VERSION) + }; + // Tmux and many terminal defaults are 24 rows tall. The full logo + subtitle // pushes the boot box out of view, so fall back to a compact header. if self.rows < 36 { execute!( self.stdout, MoveTo(0, 1), - SetForegroundColor(colors::HEADER), + SetForegroundColor(header_color), SetAttribute(Attribute::Bold), Print(format!( "{:^width$}", - "CAS Factory Boot", + compact_title, width = self.cols as usize )), SetAttribute(Attribute::Reset), MoveTo(0, 2), - SetForegroundColor(colors::LABEL), + SetForegroundColor(label_color), Print(format!( "{:^width$}", - format!("Coding Agent System • v{}", APP_VERSION), + compact_subtitle, width = self.cols as usize )), )?; @@ -187,7 +334,11 @@ impl BootScreen { } let delay = if self.skip_animation { 0 } else { 35 }; - let logo = if self.cols >= 100 { LOGO } else { LOGO_SMALL }; + let logo = if self.minions_theme { + if self.cols >= 100 { MINION_LOGO } else { MINION_LOGO_SMALL } + } else { + if self.cols >= 100 { LOGO } else { LOGO_SMALL } + }; let logo_lines: Vec<&str> = logo.lines().filter(|l| !l.is_empty()).collect(); // Starting row with top padding @@ -202,7 +353,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO_GLOW), + SetForegroundColor(logo_glow), SetAttribute(Attribute::Bold), Print(line), SetAttribute(Attribute::Reset) @@ -214,7 +365,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; self.stdout.flush()?; @@ -224,7 +375,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; } @@ -237,19 +388,19 @@ impl BootScreen { execute!( self.stdout, MoveTo(0, subtitle_row), - SetForegroundColor(colors::HEADER), + SetForegroundColor(header_color), SetAttribute(Attribute::Bold), Print(format!( "{:^width$}", - "═══ Coding Agent System ═══", + title, width = self.cols as usize )), SetAttribute(Attribute::Reset), MoveTo(0, subtitle_row + 1), - SetForegroundColor(colors::LABEL), + SetForegroundColor(label_color), Print(format!( "{:^width$}", - format!("Multi-Agent Orchestration • v{}", APP_VERSION), + subtitle, width = self.cols as usize )), )?; @@ -293,7 +444,7 @@ impl BootScreen { self.steps_row = steps_row; // Draw box outline - execute!(self.stdout, SetForegroundColor(colors::BOX))?; + execute!(self.stdout, SetForegroundColor(themed!(self, BOX)))?; // Top border with double line for emphasis execute!( @@ -352,11 +503,11 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 1, row), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("─".repeat(side_len)), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(&label_with_padding), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("─".repeat(right_side)) )?; Ok(()) @@ -370,9 +521,9 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 2, row), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(format!("{label:>12}: ")), - SetForegroundColor(colors::VALUE), + SetForegroundColor(themed!(self, VALUE)), Print(value) )?; Ok(()) @@ -381,12 +532,12 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::PENDING), + SetForegroundColor(themed!(self, PENDING)), Print(SPINNER_FRAMES[0]), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(" ...") )?; self.stdout.flush()?; @@ -402,7 +553,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::PENDING), + SetForegroundColor(themed!(self, PENDING)), Print(SPINNER_FRAMES[frame_idx]) )?; self.stdout.flush()?; @@ -415,10 +566,10 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::OK), + SetForegroundColor(themed!(self, OK)), Print("✓"), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), Print(" ") // Clear any remnants )?; @@ -429,14 +580,14 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::ERROR), + SetForegroundColor(themed!(self, ERROR)), Print("✗"), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(" — "), - SetForegroundColor(colors::ERROR), + SetForegroundColor(themed!(self, ERROR)), Print(truncate_path(error, 30)) )?; self.stdout.flush()?; @@ -454,9 +605,9 @@ impl BootScreen { "worker" }; let role_color = if is_supervisor { - colors::SUPERVISOR + themed!(self, SUPERVISOR) } else { - colors::WORKER + themed!(self, WORKER) }; let bar_width = 24; let name_width = 14; @@ -467,17 +618,17 @@ impl BootScreen { SetForegroundColor(role_color), Print(format!("{role:>10}")), Print(" "), - SetForegroundColor(colors::VALUE), + SetForegroundColor(themed!(self, VALUE)), Print(format!("{name: 0 { 1 } else { 0 }; + let done_color = themed!(self, PROGRESS_DONE); + let empty_color = themed!(self, PROGRESS_EMPTY); + // Move to progress bar position execute!( self.stdout, MoveTo(self.box_left + 4 + 12 + name_width as u16 + 3, row), - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(done_color), Print("█".repeat(full_chars)) )?; @@ -506,7 +660,7 @@ impl BootScreen { if partial_char_idx > 0 { execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(done_color), Print(PROGRESS_CHARS[partial_char_idx - 1]) )?; } @@ -514,7 +668,7 @@ impl BootScreen { // Draw empty portion execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_EMPTY), + SetForegroundColor(empty_color), Print("░".repeat(empty_chars)) )?; @@ -528,12 +682,12 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4 + 12 + name_width as u16 + 3, row), - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(themed!(self, PROGRESS_DONE)), Print("█".repeat(bar_width)), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("▌"), Print(" "), - SetForegroundColor(colors::OK), + SetForegroundColor(themed!(self, OK)), SetAttribute(Attribute::Bold), Print("READY"), SetAttribute(Attribute::Reset) @@ -542,13 +696,23 @@ impl BootScreen { Ok(()) } pub(crate) fn show_ready(&mut self, final_row: u16) -> std::io::Result<()> { + let ready_color = themed!(self, READY); + let glow_color = themed!(self, LOGO_GLOW); + let label_color = themed!(self, LABEL); + + let (ready_text, launch_text) = if self.minions_theme { + (" BANANA!", " — Bee-do Bee-do Bee-do") + } else { + (" SYSTEM READY", " — Launching interface") + }; + if !self.skip_animation { // Pulsing animation before showing ready for _ in 0..3 { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::LOGO_GLOW), + SetForegroundColor(glow_color), SetAttribute(Attribute::Bold), Print("●"), SetAttribute(Attribute::Reset), @@ -559,7 +723,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::READY), + SetForegroundColor(ready_color), Print("○"), )?; self.stdout.flush()?; @@ -571,10 +735,10 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::READY), + SetForegroundColor(ready_color), SetAttribute(Attribute::Bold), Print("▶"), - Print(" SYSTEM READY"), + Print(ready_text), SetAttribute(Attribute::Reset), )?; self.stdout.flush()?; @@ -582,13 +746,13 @@ impl BootScreen { if !self.skip_animation { thread::sleep(Duration::from_millis(200)); + let ready_len = ready_text.len() as u16 + 1; // +1 for ▶ // Type out the launching message - let message = " — Launching interface"; - for (i, ch) in message.chars().enumerate() { + for (i, ch) in launch_text.chars().enumerate() { execute!( self.stdout, - MoveTo(self.box_left + 4 + 16 + i as u16, final_row), - SetForegroundColor(colors::LABEL), + MoveTo(self.box_left + 4 + ready_len + i as u16, final_row), + SetForegroundColor(label_color), Print(ch) )?; self.stdout.flush()?; diff --git a/cas-cli/src/ui/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 5d5fe5ec1..91305bae6 100644 --- a/cas-cli/src/ui/theme/colors.rs +++ b/cas-cli/src/ui/theme/colors.rs @@ -127,6 +127,30 @@ impl ColorPalette { } } + /// Minions theme variant - yellow primary, denim blue secondary + pub fn minions(is_dark: bool) -> Self { + let base = if is_dark { Self::dark() } else { Self::light() }; + Self { + // Override primary accent from teal to Minion yellow + primary_100: Color::Rgb(255, 245, 157), // Light banana + primary_200: Color::Rgb(255, 235, 59), // Bright yellow + primary_300: Color::Rgb(255, 213, 0), // Minion yellow + primary_400: Color::Rgb(255, 193, 7), // Amber accent + primary_500: Color::Rgb(255, 160, 0), // Deep amber + + // Override info to denim blue (overalls) + info: Color::Rgb(65, 105, 225), // Royal blue / denim + info_dim: Color::Rgb(33, 53, 113), // Dark denim + + // Override cyan to goggle silver + cyan: Color::Rgb(192, 200, 210), // Goggle silver + cyan_dim: Color::Rgb(96, 100, 105), // Dark goggle + + // Keep everything else from the base + ..base + } + } + /// High contrast accessibility variant pub fn high_contrast() -> Self { Self { diff --git a/cas-cli/src/ui/theme/config.rs b/cas-cli/src/ui/theme/config.rs index 4acee496e..adbab9d79 100644 --- a/cas-cli/src/ui/theme/config.rs +++ b/cas-cli/src/ui/theme/config.rs @@ -16,6 +16,36 @@ pub enum ThemeMode { HighContrast, } +/// Theme variant selection (cosmetic flavor) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeVariant { + #[default] + Default, + Minions, +} + +impl std::fmt::Display for ThemeVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ThemeVariant::Default => write!(f, "default"), + ThemeVariant::Minions => write!(f, "minions"), + } + } +} + +impl std::str::FromStr for ThemeVariant { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "default" => Ok(ThemeVariant::Default), + "minions" => Ok(ThemeVariant::Minions), + _ => Err(format!("Unknown theme variant: {s}")), + } + } +} + impl std::fmt::Display for ThemeMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -45,12 +75,17 @@ pub struct ThemeConfig { /// Theme mode: dark, light, or high_contrast #[serde(default)] pub mode: ThemeMode, + + /// Theme variant: default or minions + #[serde(default)] + pub variant: ThemeVariant, } /// Active theme instance with computed styles #[derive(Debug, Clone)] pub struct ActiveTheme { pub mode: ThemeMode, + pub variant: ThemeVariant, pub is_dark: bool, pub palette: Palette, pub styles: Styles, @@ -59,22 +94,33 @@ pub struct ActiveTheme { impl ActiveTheme { /// Create theme from configuration pub fn from_config(config: &ThemeConfig) -> Self { - Self::from_mode(config.mode) + Self::from_mode_and_variant(config.mode, config.variant) } - /// Create theme from mode + /// Create theme from mode (default variant) pub fn from_mode(mode: ThemeMode) -> Self { - let (colors, is_dark) = match mode { + Self::from_mode_and_variant(mode, ThemeVariant::Default) + } + + /// Create theme from mode and variant + pub fn from_mode_and_variant(mode: ThemeMode, variant: ThemeVariant) -> Self { + let (base_colors, is_dark) = match mode { ThemeMode::Dark => (ColorPalette::dark(), true), ThemeMode::Light => (ColorPalette::light(), false), ThemeMode::HighContrast => (ColorPalette::high_contrast(), true), }; + let colors = match variant { + ThemeVariant::Default => base_colors, + ThemeVariant::Minions => ColorPalette::minions(is_dark), + }; + let palette = Palette::from_colors(colors, is_dark); let styles = Styles::from_palette(&palette); Self { mode, + variant, is_dark, palette, styles, @@ -114,6 +160,11 @@ impl ActiveTheme { None => Self::detect(), } } + + /// Check if the minions variant is active + pub fn is_minions(&self) -> bool { + self.variant == ThemeVariant::Minions + } } impl Default for ActiveTheme { diff --git a/cas-cli/src/ui/theme/icons.rs b/cas-cli/src/ui/theme/icons.rs index a25a7dc5d..2a2ec22f1 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -111,3 +111,17 @@ impl Icons { pub const AGENT_WORKER: &'static str = "W"; pub const AGENT_CI: &'static str = "C"; } + +/// Minion-themed icon overrides (used when minions variant is active) +pub struct MinionsIcons; + +impl MinionsIcons { + // Agent status indicators + pub const AGENT_ACTIVE: &'static str = "\u{1F34C}"; // 🍌 + pub const AGENT_IDLE: &'static str = "\u{1F441}"; // 👁 + pub const AGENT_DEAD: &'static str = "\u{1F4A4}"; // 💤 + + // Agent types + pub const AGENT_WORKER: &'static str = "\u{1F34C}"; // 🍌 + pub const AGENT_SUPERVISOR: &'static str = "\u{1F576}"; // 🕶 (Gru's glasses) +} diff --git a/cas-cli/src/ui/theme/mod.rs b/cas-cli/src/ui/theme/mod.rs index f311e77b0..6c0c28260 100644 --- a/cas-cli/src/ui/theme/mod.rs +++ b/cas-cli/src/ui/theme/mod.rs @@ -13,8 +13,8 @@ mod styles; pub use agent_colors::{get_agent_color, register_agent_color, team_color_rgb}; pub use colors::ColorPalette; -pub use config::{ActiveTheme, ThemeConfig, ThemeMode}; +pub use config::{ActiveTheme, ThemeConfig, ThemeMode, ThemeVariant}; pub use detect::detect_background_theme; -pub use icons::Icons; +pub use icons::{Icons, MinionsIcons}; pub use palette::Palette; pub use styles::Styles; diff --git a/crates/cas-factory/src/config.rs b/crates/cas-factory/src/config.rs index 45d96d570..98b63265e 100644 --- a/crates/cas-factory/src/config.rs +++ b/crates/cas-factory/src/config.rs @@ -189,6 +189,8 @@ pub struct FactoryConfig { /// UUID for the team lead's Claude Code session. /// Used as `leadSessionId` in config.json and passed as `--session-id` to the supervisor. pub lead_session_id: Option, + /// Use Minions theme for boot screen, names, and colors + pub minions_theme: bool, } impl Default for FactoryConfig { @@ -211,6 +213,7 @@ impl Default for FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } } From c734005dd1ba07bade5fe5202dcaeb33a48abfd1 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:52:22 -0400 Subject: [PATCH 07/22] fix(theme): improve minion ASCII art, wire MinionsIcons into TUI, fix test compilation - Replace generic box ASCII art with recognizable minion character (pill body, goggles with eyes, overalls, "BANANA" text) - Wire MinionsIcons (banana/eye/sleep emoji) into agent_status_icon() and agent_status_icon_simple() via is_minions() theme flag - Add missing minions_theme field to test_config() in core.rs and factory_integration.rs - Fix pre-existing mut warnings in notify.rs tests (lines 132, 155) Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/ui/factory/boot/screen.rs | 37 +++++++++++------- .../src/ui/factory/director/agent_helpers.rs | 38 ++++++++++++++----- .../src/ui/factory/director/factory_radar.rs | 3 +- .../ui/factory/director/mission_workers.rs | 3 +- crates/cas-factory/src/core.rs | 1 + crates/cas-factory/src/notify.rs | 4 +- .../cas-factory/tests/factory_integration.rs | 1 + 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 02061bb33..3b4e59bf8 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -199,24 +199,35 @@ const LOGO_SMALL: &str = r#" ╚═══════════════════════════════════════════════════════╝ "#; -/// Minion ASCII art logo +/// Minion ASCII art logo — pill-shaped body, goggles, overalls const MINION_LOGO: &str = r#" - ╭──────────╮ - ╭┤ ╭────╮ ├╮ - │╰──┤ ●● ├──╯│ - │ ╰────╯ │ - │ ╭────╮ │ - │ │ │ │ - ╰───┤ ├───╯ - ╰────╯ + ▄██████████▄ + ██ ██ + ██ ▄██████▄ ██ + ██ ██ ◉ ◉ ██ ██ + ██ ██ ██ ██ + ██ ▀██████▀ ██ + ██ ╭╌╌╌╌╮ ██ + ██ ┊ ┊ ██ + █▌ ╰╌╌╌╌╯ ▐█ + ▐█ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ █▌ + ▐█ █ B A N A N A █ █▌ + ▐█ █▄▄▄▄▄▄▄▄▄▄▄▄█ █▌ + █▌ ▐█ + █▌ ██ ██ ▐█ + ██ ██ ██ ██ + ████ ██ ████ + ██ "#; /// Smaller minion for narrow/short terminals const MINION_LOGO_SMALL: &str = r#" - ╭──────╮ - │ (●●) │ - │ ╭──╮ │ - ╰─┤ ├─╯ + ▄██████▄ + ██ (◉◉) ██ + ██ ╰──╯ ██ + █▌▐████▌▐█ + █▌ │ │ ▐█ + ▀▀ ▀▀ "#; /// Braille spinner frames for smooth animation diff --git a/cas-cli/src/ui/factory/director/agent_helpers.rs b/cas-cli/src/ui/factory/director/agent_helpers.rs index 341e2b53d..656ee2905 100644 --- a/cas-cli/src/ui/factory/director/agent_helpers.rs +++ b/cas-cli/src/ui/factory/director/agent_helpers.rs @@ -9,7 +9,7 @@ use chrono::Utc; use ratatui::prelude::Color; use super::data::DirectorData; -use crate::ui::theme::{Icons, Palette}; +use crate::ui::theme::{Icons, MinionsIcons, Palette}; /// Agents with no heartbeat for this many seconds are considered disconnected. pub const HEARTBEAT_TIMEOUT_SECS: i64 = 300; @@ -35,14 +35,25 @@ pub fn is_disconnected(agent: &cas_factory::AgentSummary) -> bool { pub fn agent_status_icon( agent: &cas_factory::AgentSummary, palette: &Palette, + minions: bool, ) -> (&'static str, Color) { if is_disconnected(agent) { - ("\u{2298}", palette.agent_dead) // ⊘ + let icon = if minions { MinionsIcons::AGENT_DEAD } else { "\u{2298}" }; + (icon, palette.agent_dead) } else { match agent.status { - AgentStatus::Active => (Icons::CIRCLE_FILLED, palette.agent_active), - AgentStatus::Idle => (Icons::CIRCLE_HALF, palette.agent_idle), - _ => (Icons::CIRCLE_EMPTY, palette.agent_dead), + AgentStatus::Active => { + let icon = if minions { MinionsIcons::AGENT_ACTIVE } else { Icons::CIRCLE_FILLED }; + (icon, palette.agent_active) + } + AgentStatus::Idle => { + let icon = if minions { MinionsIcons::AGENT_IDLE } else { Icons::CIRCLE_HALF }; + (icon, palette.agent_idle) + } + _ => { + let icon = if minions { MinionsIcons::AGENT_DEAD } else { Icons::CIRCLE_EMPTY }; + (icon, palette.agent_dead) + } } } } @@ -51,11 +62,20 @@ pub fn agent_status_icon( pub fn agent_status_icon_simple( agent: &cas_factory::AgentSummary, palette: &Palette, + minions: bool, ) -> (&'static str, Color) { - match agent.status { - AgentStatus::Active => ("\u{25cf}", palette.agent_active), // ● - AgentStatus::Idle => ("\u{25cb}", palette.agent_idle), // ○ - _ => ("\u{2298}", palette.agent_dead), // ⊘ + if minions { + match agent.status { + AgentStatus::Active => (MinionsIcons::AGENT_ACTIVE, palette.agent_active), + AgentStatus::Idle => (MinionsIcons::AGENT_IDLE, palette.agent_idle), + _ => (MinionsIcons::AGENT_DEAD, palette.agent_dead), + } + } else { + match agent.status { + AgentStatus::Active => ("\u{25cf}", palette.agent_active), // ● + AgentStatus::Idle => ("\u{25cb}", palette.agent_idle), // ○ + _ => ("\u{2298}", palette.agent_dead), // ⊘ + } } } diff --git a/cas-cli/src/ui/factory/director/factory_radar.rs b/cas-cli/src/ui/factory/director/factory_radar.rs index fc924baeb..862d44e18 100644 --- a/cas-cli/src/ui/factory/director/factory_radar.rs +++ b/cas-cli/src/ui/factory/director/factory_radar.rs @@ -240,7 +240,8 @@ fn render_worker_list( } // Status indicator - let (status_char, status_color) = agent_helpers::agent_status_icon_simple(agent, palette); + let (status_char, status_color) = + agent_helpers::agent_status_icon_simple(agent, palette, theme.is_minions()); let is_selected = selected == Some(idx); let name_style = if is_selected { diff --git a/cas-cli/src/ui/factory/director/mission_workers.rs b/cas-cli/src/ui/factory/director/mission_workers.rs index 825e81f5e..e38def9cd 100644 --- a/cas-cli/src/ui/factory/director/mission_workers.rs +++ b/cas-cli/src/ui/factory/director/mission_workers.rs @@ -67,7 +67,8 @@ pub fn render_workers_panel_with_focus( // Status icon and color let is_disconnected = agent_helpers::is_disconnected(agent); - let (status_icon, icon_color) = agent_helpers::agent_status_icon(agent, palette); + let (status_icon, icon_color) = + agent_helpers::agent_status_icon(agent, palette, theme.is_minions()); let selection_marker = if is_selected { "\u{25b8} " } else { " " }; let name_width = agent.name.len(); diff --git a/crates/cas-factory/src/core.rs b/crates/cas-factory/src/core.rs index 70b396452..7f7d38f29 100644 --- a/crates/cas-factory/src/core.rs +++ b/crates/cas-factory/src/core.rs @@ -470,6 +470,7 @@ mod tests { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } diff --git a/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 37876b552..3aeda670c 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -129,7 +129,7 @@ mod tests { #[tokio::test] async fn notify_and_recv_round_trip() { let dir = TempDir::new().unwrap(); - let notifier = DaemonNotifier::bind(dir.path()).unwrap(); + let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); // Send a notification from the "worker" side notify_daemon(dir.path()).unwrap(); @@ -152,7 +152,7 @@ mod tests { #[tokio::test] async fn drain_clears_pending_notifications() { let dir = TempDir::new().unwrap(); - let notifier = DaemonNotifier::bind(dir.path()).unwrap(); + let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); // Send multiple notifications for _ in 0..5 { diff --git a/crates/cas-factory/tests/factory_integration.rs b/crates/cas-factory/tests/factory_integration.rs index 792905829..ce46efe87 100644 --- a/crates/cas-factory/tests/factory_integration.rs +++ b/crates/cas-factory/tests/factory_integration.rs @@ -195,6 +195,7 @@ fn test_config() -> FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } From c630a920dbd507a7e3134dde88503a519f4980bf Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:00:42 -0400 Subject: [PATCH 08/22] fix(theme): improve minion ASCII art and use canonical character names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign MINION_LOGO with better pill-shaped body silhouette, symmetric goggle strap, arm stubs (─┤/├─), and cleaner feet - Replace 8 non-canonical worker names (larry, tom, chris, john, paul, mike, ken, donny) with actual Minion film characters (jorge, otto, steve, herb, pete, donnie, abel, walter) - Add otto (main character from Minions: The Rise of Gru) Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/orchestration/names.rs | 4 ++-- cas-cli/src/ui/factory/boot/screen.rs | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 046ec66a2..83431fd6c 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -11,8 +11,8 @@ use std::collections::HashSet; const MINION_WORKERS: &[&str] = &[ "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", - "norbert", "larry", "tom", "chris", "john", "paul", "mike", "ken", "mel", - "lance", "donny", + "norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", + "abel", "lance", "walter", ]; const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 3b4e59bf8..0b59212c3 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -201,23 +201,21 @@ const LOGO_SMALL: &str = r#" /// Minion ASCII art logo — pill-shaped body, goggles, overalls const MINION_LOGO: &str = r#" - ▄██████████▄ - ██ ██ - ██ ▄██████▄ ██ - ██ ██ ◉ ◉ ██ ██ - ██ ██ ██ ██ - ██ ▀██████▀ ██ - ██ ╭╌╌╌╌╮ ██ - ██ ┊ ┊ ██ - █▌ ╰╌╌╌╌╯ ▐█ + ▄████████████▄ + ██ ██ + ██ ▄██████████▄ ██ + ██ █ ◉ ◉ █ ██ + ██ █ █ ██ + ██ ▀██████████▀ ██ + ██ ╭──────╮ ██ + ─┤ ██ │ ╰──╯ │ ██ ├─ + ██ ╰──────╯ ██ ▐█ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ █▌ ▐█ █ B A N A N A █ █▌ ▐█ █▄▄▄▄▄▄▄▄▄▄▄▄█ █▌ - █▌ ▐█ - █▌ ██ ██ ▐█ - ██ ██ ██ ██ - ████ ██ ████ - ██ + ██ ██ + ██ ██ ██ ██ + ▀██▀ ▀██▀ "#; /// Smaller minion for narrow/short terminals From dfc86b5091b71db29b7bd19d8d94593c8a94358a Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:08:37 -0400 Subject: [PATCH 09/22] fix(theme): replace non-canonical minion name 'lance' with 'tony' Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/orchestration/names.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 83431fd6c..a064b218d 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; const MINION_WORKERS: &[&str] = &[ "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", "norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", - "abel", "lance", "walter", + "abel", "tony", "walter", ]; const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; From e11e1f80d1412a7be9d5eb30f9dc40c68ae4e289 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:20:22 -0400 Subject: [PATCH 10/22] fix(factory): fix drain_clears_pending_notifications test The test was failing because try_recv on a Tokio UnixDatagram requires the socket to be registered with the reactor first. Added an initial recv().await call before the drain test sequence to ensure the Tokio socket is properly registered. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-factory/src/notify.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 3aeda670c..e020ca042 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -154,12 +154,15 @@ mod tests { let dir = TempDir::new().unwrap(); let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); - // Send multiple notifications + // First recv registers the tokio socket with the reactor — without + // this, try_recv inside drain() will never see pending datagrams. + notify_daemon(dir.path()).unwrap(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(100), notifier.recv()).await; + + // Now send several more notifications for _ in 0..5 { notify_daemon(dir.path()).unwrap(); } - - // Small delay so datagrams land tokio::time::sleep(std::time::Duration::from_millis(10)).await; // Drain should clear all pending From 913626bd11e34c52c8378e91a367a046f5b2769b Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:25:13 -0400 Subject: [PATCH 11/22] test: add coverage for OSC 52, theme variant, minions palette and icons - OSC 52: base64 encoding, empty string, unicode, multiline round-trip - ThemeVariant: default value, Display, FromStr, serde round-trip - ActiveTheme: minions config wiring, is_minions() check - ColorPalette::minions(): yellow primary, denim blue info, differs from dark, preserves base bg - MinionsIcons: non-empty constants, differ from default circles Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/factory/daemon/runtime/client_input.rs | 36 ++++++++++++++ cas-cli/src/ui/theme/colors.rs | 45 +++++++++++++++++ cas-cli/src/ui/theme/config.rs | 49 +++++++++++++++++++ cas-cli/src/ui/theme/icons.rs | 22 +++++++++ 4 files changed, 152 insertions(+) diff --git a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs index 67de7bb8e..22c77891d 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs @@ -872,6 +872,42 @@ fn osc52_copy_sequence(text: &str) -> String { format!("\x1b]52;c;{}\x1b\\", encoded) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc52_encodes_text_as_base64() { + let seq = osc52_copy_sequence("hello"); + // "hello" in base64 is "aGVsbG8=" + assert_eq!(seq, "\x1b]52;c;aGVsbG8=\x1b\\"); + } + + #[test] + fn osc52_handles_empty_string() { + let seq = osc52_copy_sequence(""); + assert_eq!(seq, "\x1b]52;c;\x1b\\"); + } + + #[test] + fn osc52_handles_unicode() { + let seq = osc52_copy_sequence("🍌"); + let expected_b64 = base64::engine::general_purpose::STANDARD.encode("🍌".as_bytes()); + assert_eq!(seq, format!("\x1b]52;c;{}\x1b\\", expected_b64)); + } + + #[test] + fn osc52_handles_multiline() { + let seq = osc52_copy_sequence("line1\nline2\nline3"); + assert!(seq.starts_with("\x1b]52;c;")); + assert!(seq.ends_with("\x1b\\")); + // Decode and verify round-trip + let b64 = &seq[7..seq.len() - 2]; + let decoded = base64::engine::general_purpose::STANDARD.decode(b64).unwrap(); + assert_eq!(std::str::from_utf8(&decoded).unwrap(), "line1\nline2\nline3"); + } +} + fn bracketed_paste_bytes(payload: &str) -> Vec { let mut out = Vec::with_capacity(payload.len() + 12); out.extend_from_slice(b"\x1b[200~"); diff --git a/cas-cli/src/ui/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 91305bae6..1d625e3ad 100644 --- a/cas-cli/src/ui/theme/colors.rs +++ b/cas-cli/src/ui/theme/colors.rs @@ -189,3 +189,48 @@ impl ColorPalette { } } } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + #[test] + fn minions_palette_has_yellow_primary() { + let minions = ColorPalette::minions(true); + match minions.primary_300 { + Color::Rgb(r, g, _) => { + assert!(r > 200, "primary_300 red should be bright yellow, got {r}"); + assert!(g > 150, "primary_300 green should be bright yellow, got {g}"); + } + other => panic!("Expected RGB color, got {other:?}"), + } + } + + #[test] + fn minions_palette_has_denim_blue_info() { + let minions = ColorPalette::minions(true); + match minions.info { + Color::Rgb(r, _, b) => { + assert!(b > r, "info blue should exceed red for denim blue"); + assert!(b > 150, "info blue component should be strong, got {b}"); + } + other => panic!("Expected RGB color, got {other:?}"), + } + } + + #[test] + fn minions_palette_differs_from_dark() { + let dark = ColorPalette::dark(); + let minions = ColorPalette::minions(true); + assert_ne!(minions.primary_300, dark.primary_300, "primary should differ"); + assert_ne!(minions.info, dark.info, "info should differ"); + } + + #[test] + fn minions_palette_preserves_base_bg() { + let dark = ColorPalette::dark(); + let minions = ColorPalette::minions(true); + assert_eq!(minions.gray_900, dark.gray_900, "bg should inherit from dark base"); + } +} diff --git a/cas-cli/src/ui/theme/config.rs b/cas-cli/src/ui/theme/config.rs index adbab9d79..aaf7cda79 100644 --- a/cas-cli/src/ui/theme/config.rs +++ b/cas-cli/src/ui/theme/config.rs @@ -172,3 +172,52 @@ impl Default for ActiveTheme { Self::detect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_variant_default_is_default() { + assert_eq!(ThemeVariant::default(), ThemeVariant::Default); + } + + #[test] + fn theme_variant_display() { + assert_eq!(ThemeVariant::Default.to_string(), "default"); + assert_eq!(ThemeVariant::Minions.to_string(), "minions"); + } + + #[test] + fn theme_variant_from_str() { + assert_eq!("default".parse::().unwrap(), ThemeVariant::Default); + assert_eq!("minions".parse::().unwrap(), ThemeVariant::Minions); + assert_eq!("MINIONS".parse::().unwrap(), ThemeVariant::Minions); + assert!("banana".parse::().is_err()); + } + + #[test] + fn theme_variant_serde_round_trip() { + let json = serde_json::to_string(&ThemeVariant::Minions).unwrap(); + assert_eq!(json, "\"minions\""); + let parsed: ThemeVariant = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ThemeVariant::Minions); + } + + #[test] + fn active_theme_minions_variant() { + let config = ThemeConfig { + mode: ThemeMode::Dark, + variant: ThemeVariant::Minions, + }; + let theme = ActiveTheme::from_config(&config); + assert!(theme.is_minions()); + assert_eq!(theme.variant, ThemeVariant::Minions); + } + + #[test] + fn active_theme_default_is_not_minions() { + let theme = ActiveTheme::from_mode(ThemeMode::Dark); + assert!(!theme.is_minions()); + } +} diff --git a/cas-cli/src/ui/theme/icons.rs b/cas-cli/src/ui/theme/icons.rs index 2a2ec22f1..b982a1be1 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -125,3 +125,25 @@ impl MinionsIcons { pub const AGENT_WORKER: &'static str = "\u{1F34C}"; // 🍌 pub const AGENT_SUPERVISOR: &'static str = "\u{1F576}"; // 🕶 (Gru's glasses) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minions_icons_are_non_empty() { + assert!(!MinionsIcons::AGENT_ACTIVE.is_empty()); + assert!(!MinionsIcons::AGENT_IDLE.is_empty()); + assert!(!MinionsIcons::AGENT_DEAD.is_empty()); + assert!(!MinionsIcons::AGENT_WORKER.is_empty()); + assert!(!MinionsIcons::AGENT_SUPERVISOR.is_empty()); + } + + #[test] + fn minions_icons_differ_from_default_circles() { + // Default TUI uses circle icons (●/○/⊘) for agent status + assert_ne!(MinionsIcons::AGENT_ACTIVE, Icons::CIRCLE_FILLED); + assert_ne!(MinionsIcons::AGENT_IDLE, Icons::CIRCLE_EMPTY); + assert_ne!(MinionsIcons::AGENT_DEAD, Icons::CIRCLE_X); + } +} From 1cda03192ac3ca8a0e2801a51dc5c872cf6cb9be Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:53:43 -0400 Subject: [PATCH 12/22] fix(pty): fix broken mcp__cs__ tool prefixes in Codex instructions Replace all `mcp__cs__` prefixes with `mcp__cas__` in the hardcoded Codex worker and supervisor instructions in pty.rs. Extract inline instruction strings to module-level constants for maintainability. Add tests verifying Codex worker instructions use the correct mcp__cas__ prefix and supervisor instructions are present. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-pty/src/pty.rs | 66 ++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/cas-pty/src/pty.rs b/crates/cas-pty/src/pty.rs index b85ba2284..0a7c932f1 100644 --- a/crates/cas-pty/src/pty.rs +++ b/crates/cas-pty/src/pty.rs @@ -13,6 +13,15 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::mpsc; +/// Instructions injected into Codex supervisor agents via `--config developer_instructions`. +const CODEX_SUPERVISOR_INSTRUCTIONS: &str = "You are the CAS Factory Supervisor. Coordinate only: plan epics, assign tasks, monitor progress, review/merge. Never implement tasks. Use skills cas-supervisor and cas-codex-supervisor-checklist. Use MCP tools explicitly; no /cas-start, /cas-context, or /cas-end."; + +/// Instructions injected into Codex worker agents via `--config developer_instructions`. +const CODEX_WORKER_INSTRUCTIONS: &str = "You are a CAS Factory Worker. Always use CAS MCP tools for task lifecycle and coordination. On startup run `mcp__cas__coordination action=session_start name= agent_type=worker` then `mcp__cas__coordination action=whoami`, then run `mcp__cas__task action=mine`. For assigned tasks run `mcp__cas__task action=show id=` then `mcp__cas__task action=start id=` before coding. Add progress notes frequently using `mcp__cas__task action=notes id= note_type=progress notes=\"...\"`. For blockers, add blocker note, set `status=blocked`, and message supervisor via `mcp__cas__coordination action=message target=supervisor message=\"...\"`. When implementation is complete, close with `mcp__cas__task action=close id= reason=\"...\"`. If close returns verification-required guidance, immediately ask supervisor to verify/close on your behalf. Do not use /cas-start, /cas-context, or /cas-end. Stay within assigned task scope."; + +/// Prefix for the Codex worker startup prompt. The worker name is appended at runtime. +const CODEX_WORKER_STARTUP_PREFIX: &str = "I'm initiating CAS worker startup now: register this worker session, confirm identity, check assigned tasks, then start any assigned task with a progress note.\n1) Run mcp__cas__coordination action=session_start name="; + /// Configuration for spawning a PTY #[derive(Debug, Clone)] pub struct PtyConfig { @@ -252,23 +261,24 @@ impl PtyConfig { } if role == "supervisor" { - let instructions = "You are the CAS Factory Supervisor. Coordinate only: plan epics, assign tasks, monitor progress, review/merge. Never implement tasks. Use skills cas-supervisor and cas-codex-supervisor-checklist. Use MCP tools explicitly; no /cas-start, /cas-context, or /cas-end."; - let escaped = instructions.replace('"', "\\\""); + let escaped = CODEX_SUPERVISOR_INSTRUCTIONS.replace('"', "\\\""); args.push("--config".to_string()); args.push(format!("developer_instructions=\"{escaped}\"")); } else if role == "worker" { - let instructions = "You are a CAS Factory Worker. Always use CAS MCP tools for task lifecycle and coordination. On startup run `mcp__cs__coordination action=session_start name= agent_type=worker` then `mcp__cs__coordination action=whoami`, then run `mcp__cs__task action=mine`. For assigned tasks run `mcp__cs__task action=show id=` then `mcp__cs__task action=start id=` before coding. Add progress notes frequently using `mcp__cs__task action=notes id= note_type=progress notes=\"...\"`. For blockers, add blocker note, set `status=blocked`, and message supervisor via `mcp__cs__coordination action=message target=supervisor message=\"...\"`. When implementation is complete, close with `mcp__cs__task action=close id= reason=\"...\"`. If close returns verification-required guidance, immediately ask supervisor to verify/close on your behalf. Do not use /cas-start, /cas-context, or /cas-end. Stay within assigned task scope."; - let escaped = instructions.replace('"', "\\\""); + let escaped = CODEX_WORKER_INSTRUCTIONS.replace('"', "\\\""); args.push("--config".to_string()); args.push(format!("developer_instructions=\"{escaped}\"")); // Pass startup workflow as initial prompt arg so Codex executes it immediately. // This is more reliable than post-spawn typed injection, which can leave text // in the composer without submitting in some startup timing windows. - let startup_prompt = "I’m initiating CAS worker startup now: register this worker session, confirm identity, check assigned tasks, then start any assigned task with a progress note.\n1) Run mcp__cs__coordination action=session_start name="; - let startup_prompt = format!("{startup_prompt}{name}"); let startup_prompt = format!( - "{startup_prompt} agent_type=worker\n2) Run mcp__cs__coordination action=whoami\n3) Run mcp__cs__task action=mine\n4) If tasks are assigned: show/start each task and add a progress note\n5) If no tasks are assigned: send mcp__cs__coordination action=message target=supervisor confirming ready state\n6) Do NOT message target=cas. Use target=supervisor." + "{CODEX_WORKER_STARTUP_PREFIX}{name} agent_type=worker\n\ + 2) Run mcp__cas__coordination action=whoami\n\ + 3) Run mcp__cas__task action=mine\n\ + 4) If tasks are assigned: show/start each task and add a progress note\n\ + 5) If no tasks are assigned: send mcp__cas__coordination action=message target=supervisor confirming ready state\n\ + 6) Do NOT message target=cas. Use target=supervisor." ); args.push(startup_prompt); } @@ -738,6 +748,48 @@ mod tests { assert!(config.args.contains(&"gpt-5.3-codex".to_string())); } + #[tokio::test] + async fn test_pty_config_codex_worker_uses_cas_prefix() { + let config = PtyConfig::codex( + "test-worker", + "worker", + PathBuf::from("/tmp"), + None, + None, + None, + None, + None, + ); + let all_args = config.args.join(" "); + assert!( + all_args.contains("mcp__cas__"), + "Codex worker instructions should use mcp__cas__ prefix" + ); + assert!( + !all_args.contains("mcp__cs__"), + "Codex worker instructions should NOT use mcp__cs__ prefix" + ); + } + + #[tokio::test] + async fn test_pty_config_codex_supervisor_instructions() { + let config = PtyConfig::codex( + "test-supervisor", + "supervisor", + PathBuf::from("/tmp"), + None, + None, + None, + None, + None, + ); + let all_args = config.args.join(" "); + assert!( + all_args.contains("CAS Factory Supervisor"), + "Codex supervisor should have supervisor instructions" + ); + } + #[tokio::test] async fn test_pty_config_claude_with_teams() { let teams = TeamsSpawnConfig { From 7a739c5b7aac6e95c13787d916989314aea773b8 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:54:24 -0400 Subject: [PATCH 13/22] fix(messaging): remove per-message response hints from format_agent_message Remove the response hint ("To respond, use: coordination...") that was appended to every agent message. This instruction is already provided at session start via the cas-worker skill and is also enforced by the PreToolUse hook that blocks SendMessage in factory mode. Removing the per-message hint saves tokens on every inter-agent exchange. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mcp/tools/service/agent_search_system/message.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs index 2e615c27b..12e11803b 100644 --- a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs +++ b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs @@ -1,13 +1,6 @@ use crate::mcp::tools::service::imports::*; impl CasService { - fn format_agent_message(message: &str, _from: &str, respond_to: &str) -> String { - let response_hint = format!( - "To respond, use: coordination action=message target={respond_to} message=\"...\" summary=\"...\"\n\nDO NOT USE SENDMESSAGE." - ); - format!("{}\n\n{}", message.trim_end(), response_hint) - } - pub(in crate::mcp::tools::service) async fn message_send( &self, req: AgentRequest, @@ -252,13 +245,12 @@ impl CasService { .unwrap_or_else(|| source.clone()) }; - let wrapped_message = Self::format_agent_message(&message, &display_name, &display_name); let factory_session = std::env::var("CAS_FACTORY_SESSION").ok(); let message_id = queue .enqueue_with_summary( &display_name, &resolved_target, - &wrapped_message, + &message, factory_session.as_deref(), Some(summary.as_str()), ) From 8fd6cecf3e7e2c4a776ae2ed9fd5ce722f9019bb Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:55:15 -0400 Subject: [PATCH 14/22] fix(pty): revert to mcp__cs__ prefix, keep extraction to constants Revert the prefix change per supervisor guidance: mcp__cs__ is the correct Codex tool prefix (configured in harness.rs). Keep the extraction of inline instruction strings to module-level constants (CODEX_SUPERVISOR_INSTRUCTIONS, CODEX_WORKER_INSTRUCTIONS, CODEX_WORKER_STARTUP_PREFIX) for maintainability. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-pty/src/pty.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/cas-pty/src/pty.rs b/crates/cas-pty/src/pty.rs index 0a7c932f1..9e642ab6a 100644 --- a/crates/cas-pty/src/pty.rs +++ b/crates/cas-pty/src/pty.rs @@ -17,10 +17,10 @@ use tokio::sync::mpsc; const CODEX_SUPERVISOR_INSTRUCTIONS: &str = "You are the CAS Factory Supervisor. Coordinate only: plan epics, assign tasks, monitor progress, review/merge. Never implement tasks. Use skills cas-supervisor and cas-codex-supervisor-checklist. Use MCP tools explicitly; no /cas-start, /cas-context, or /cas-end."; /// Instructions injected into Codex worker agents via `--config developer_instructions`. -const CODEX_WORKER_INSTRUCTIONS: &str = "You are a CAS Factory Worker. Always use CAS MCP tools for task lifecycle and coordination. On startup run `mcp__cas__coordination action=session_start name= agent_type=worker` then `mcp__cas__coordination action=whoami`, then run `mcp__cas__task action=mine`. For assigned tasks run `mcp__cas__task action=show id=` then `mcp__cas__task action=start id=` before coding. Add progress notes frequently using `mcp__cas__task action=notes id= note_type=progress notes=\"...\"`. For blockers, add blocker note, set `status=blocked`, and message supervisor via `mcp__cas__coordination action=message target=supervisor message=\"...\"`. When implementation is complete, close with `mcp__cas__task action=close id= reason=\"...\"`. If close returns verification-required guidance, immediately ask supervisor to verify/close on your behalf. Do not use /cas-start, /cas-context, or /cas-end. Stay within assigned task scope."; +const CODEX_WORKER_INSTRUCTIONS: &str = "You are a CAS Factory Worker. Always use CAS MCP tools for task lifecycle and coordination. On startup run `mcp__cs__coordination action=session_start name= agent_type=worker` then `mcp__cs__coordination action=whoami`, then run `mcp__cs__task action=mine`. For assigned tasks run `mcp__cs__task action=show id=` then `mcp__cs__task action=start id=` before coding. Add progress notes frequently using `mcp__cs__task action=notes id= note_type=progress notes=\"...\"`. For blockers, add blocker note, set `status=blocked`, and message supervisor via `mcp__cs__coordination action=message target=supervisor message=\"...\"`. When implementation is complete, close with `mcp__cs__task action=close id= reason=\"...\"`. If close returns verification-required guidance, immediately ask supervisor to verify/close on your behalf. Do not use /cas-start, /cas-context, or /cas-end. Stay within assigned task scope."; /// Prefix for the Codex worker startup prompt. The worker name is appended at runtime. -const CODEX_WORKER_STARTUP_PREFIX: &str = "I'm initiating CAS worker startup now: register this worker session, confirm identity, check assigned tasks, then start any assigned task with a progress note.\n1) Run mcp__cas__coordination action=session_start name="; +const CODEX_WORKER_STARTUP_PREFIX: &str = "I'm initiating CAS worker startup now: register this worker session, confirm identity, check assigned tasks, then start any assigned task with a progress note.\n1) Run mcp__cs__coordination action=session_start name="; /// Configuration for spawning a PTY #[derive(Debug, Clone)] @@ -274,10 +274,10 @@ impl PtyConfig { // in the composer without submitting in some startup timing windows. let startup_prompt = format!( "{CODEX_WORKER_STARTUP_PREFIX}{name} agent_type=worker\n\ - 2) Run mcp__cas__coordination action=whoami\n\ - 3) Run mcp__cas__task action=mine\n\ + 2) Run mcp__cs__coordination action=whoami\n\ + 3) Run mcp__cs__task action=mine\n\ 4) If tasks are assigned: show/start each task and add a progress note\n\ - 5) If no tasks are assigned: send mcp__cas__coordination action=message target=supervisor confirming ready state\n\ + 5) If no tasks are assigned: send mcp__cs__coordination action=message target=supervisor confirming ready state\n\ 6) Do NOT message target=cas. Use target=supervisor." ); args.push(startup_prompt); @@ -749,7 +749,7 @@ mod tests { } #[tokio::test] - async fn test_pty_config_codex_worker_uses_cas_prefix() { + async fn test_pty_config_codex_worker_uses_cs_prefix() { let config = PtyConfig::codex( "test-worker", "worker", @@ -762,12 +762,8 @@ mod tests { ); let all_args = config.args.join(" "); assert!( - all_args.contains("mcp__cas__"), - "Codex worker instructions should use mcp__cas__ prefix" - ); - assert!( - !all_args.contains("mcp__cs__"), - "Codex worker instructions should NOT use mcp__cs__ prefix" + all_args.contains("mcp__cs__"), + "Codex worker instructions should use mcp__cs__ prefix" ); } From 62ffe29513a421ac08f594631f32ad844ece49ea Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:55:49 -0400 Subject: [PATCH 15/22] perf: cache supervisor_owned_workers() with thread_local OnceCell The function reads CAS_AGENT_ROLE and CAS_FACTORY_WORKER_NAMES env vars and parses CSV on every call across 6+ call sites. Since env vars don't change during a session, cache the result per-thread using thread_local OnceCell. Thread-local (vs process-global OnceLock) is used so that tests which set different env vars per test thread continue to work. Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/mcp/tools/service/factory_ops.rs | 39 +++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/cas-cli/src/mcp/tools/service/factory_ops.rs b/cas-cli/src/mcp/tools/service/factory_ops.rs index 8f7fe02e1..aa626e7fc 100644 --- a/cas-cli/src/mcp/tools/service/factory_ops.rs +++ b/cas-cli/src/mcp/tools/service/factory_ops.rs @@ -818,22 +818,33 @@ impl CasService { /// Returns the set of worker names this supervisor owns, derived from the `CAS_FACTORY_WORKER_NAMES` /// environment variable. Returns `None` when not running as a supervisor or when the variable is /// absent, meaning no scoping should be applied. +/// +/// The result is cached per-thread since env vars don't change during a session. fn supervisor_owned_workers() -> Option> { - let role = std::env::var("CAS_AGENT_ROLE").unwrap_or_default(); - if !role.eq_ignore_ascii_case("supervisor") { - return None; + thread_local! { + static CACHE: std::cell::OnceCell>> = + const { std::cell::OnceCell::new() }; } - let csv = std::env::var("CAS_FACTORY_WORKER_NAMES").ok()?; - if csv.trim().is_empty() { - return None; - } - Some( - csv.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToOwned::to_owned) - .collect(), - ) + CACHE.with(|cell| { + cell.get_or_init(|| { + let role = std::env::var("CAS_AGENT_ROLE").unwrap_or_default(); + if !role.eq_ignore_ascii_case("supervisor") { + return None; + } + let csv = std::env::var("CAS_FACTORY_WORKER_NAMES").ok()?; + if csv.trim().is_empty() { + return None; + } + Some( + csv.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .collect(), + ) + }) + .clone() + }) } fn run_git(path: &std::path::Path, args: &[&str]) -> std::result::Result { From 6d9a1e55709101cf8bd1d02c59b0bbb4cf51f8bd Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:57:14 -0400 Subject: [PATCH 16/22] fix(messaging): preserve full message content on undeliverable prompt queue messages Remove the 500-char truncation when re-queuing undelivered messages to the supervisor. The full original message is now preserved in the system-notice, so the supervisor can properly re-send or act on the complete content instead of a truncated fragment. Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/ui/factory/daemon/runtime/queue_and_events.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas-cli/src/ui/factory/daemon/runtime/queue_and_events.rs b/cas-cli/src/ui/factory/daemon/runtime/queue_and_events.rs index 8d543a74b..ab3a972c7 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/queue_and_events.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/queue_and_events.rs @@ -337,7 +337,7 @@ impl FactoryDaemon { ", queued.source, pane_target, - queued.prompt.chars().take(500).collect::() + &queued.prompt ); let _ = queue.enqueue_with_session( super::teams::DIRECTOR_AGENT_NAME, From 89e0165f9929b24b295a5ba0a701efabef878b38 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 11:04:44 -0400 Subject: [PATCH 17/22] feat(queue): add priority levels to prompt queue Add priority support to the prompt queue so important messages (blockers, crashes) can jump ahead of routine status updates. Changes: - Add priority column to prompt_queue table (migration, DEFAULT 2) - Add enqueue_full() method accepting optional NotificationPriority - Change ORDER BY from created_at ASC to priority ASC, id ASC - Wire priority param through coordination message action - Add QueuedPrompt.priority field using existing NotificationPriority - Add 3 tests: priority ordering, default priority, peek_for_targets Co-Authored-By: Claude Opus 4.6 (1M context) --- .../service/agent_search_system/message.rs | 8 +- crates/cas-store/src/prompt_queue_store.rs | 178 ++++++++++++++---- 2 files changed, 148 insertions(+), 38 deletions(-) diff --git a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs index 12e11803b..08d9f602d 100644 --- a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs +++ b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs @@ -246,13 +246,19 @@ impl CasService { }; let factory_session = std::env::var("CAS_FACTORY_SESSION").ok(); + let priority = req.priority.as_deref().map(|p| match p { + "critical" | "0" => cas_store::NotificationPriority::Critical, + "high" | "1" => cas_store::NotificationPriority::High, + _ => cas_store::NotificationPriority::Normal, + }); let message_id = queue - .enqueue_with_summary( + .enqueue_full( &display_name, &resolved_target, &message, factory_session.as_deref(), Some(summary.as_str()), + priority, ) .map_err(|error| { Self::error( diff --git a/crates/cas-store/src/prompt_queue_store.rs b/crates/cas-store/src/prompt_queue_store.rs index f3c0a57a5..38144210b 100644 --- a/crates/cas-store/src/prompt_queue_store.rs +++ b/crates/cas-store/src/prompt_queue_store.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use crate::Result; use crate::recording_store::capture_message_event; +use crate::supervisor_queue_store::NotificationPriority; /// A prompt in the queue #[derive(Debug, Clone, Serialize, Deserialize)] @@ -29,6 +30,8 @@ pub struct QueuedPrompt { pub processed_at: Option>, /// Short summary for UI display pub summary: Option, + /// Message priority (lower = higher priority) + pub priority: NotificationPriority, } /// Schema for prompt queue table @@ -56,6 +59,11 @@ const PROMPT_QUEUE_SUMMARY_MIGRATION: &str = r#" ALTER TABLE prompt_queue ADD COLUMN summary TEXT; "#; +/// Add priority column for message ordering (0=Critical, 1=High, 2=Normal). +const PROMPT_QUEUE_PRIORITY_MIGRATION: &str = r#" +ALTER TABLE prompt_queue ADD COLUMN priority INTEGER NOT NULL DEFAULT 2; +"#; + /// Trait for prompt queue operations pub trait PromptQueueStore: Send + Sync { /// Initialize the store (create tables) @@ -73,7 +81,7 @@ pub trait PromptQueueStore: Send + Sync { factory_session: &str, ) -> Result; - /// Queue a prompt with session and summary for UI display + /// Queue a prompt with session, summary, and priority for UI display fn enqueue_with_summary( &self, source: &str, @@ -81,6 +89,19 @@ pub trait PromptQueueStore: Send + Sync { prompt: &str, factory_session: Option<&str>, summary: Option<&str>, + ) -> Result { + self.enqueue_full(source, target, prompt, factory_session, summary, None) + } + + /// Queue a prompt with all options including priority + fn enqueue_full( + &self, + source: &str, + target: &str, + prompt: &str, + factory_session: Option<&str>, + summary: Option<&str>, + priority: Option, ) -> Result; /// Poll for pending prompts for a specific target (marks as processed) @@ -147,6 +168,7 @@ impl SqlitePromptQueueStore { let processed_at_str: Option = row.get(5)?; let processed_at = processed_at_str.and_then(|s| Self::parse_datetime(&s)); let summary: Option = row.get(6).unwrap_or(None); + let priority: u8 = row.get(7).unwrap_or(2); Ok(QueuedPrompt { id: row.get(0)?, @@ -156,6 +178,7 @@ impl SqlitePromptQueueStore { created_at: Self::parse_datetime(&row.get::<_, String>(4)?).unwrap_or_else(Utc::now), processed_at, summary, + priority: NotificationPriority::from(priority), }) } } @@ -181,24 +204,19 @@ impl PromptQueueStore for SqlitePromptQueueStore { conn.execute_batch(PROMPT_QUEUE_SUMMARY_MIGRATION)?; } + // Add priority column if missing + let has_priority_col = conn + .prepare("SELECT priority FROM prompt_queue LIMIT 0") + .is_ok(); + if !has_priority_col { + conn.execute_batch(PROMPT_QUEUE_PRIORITY_MIGRATION)?; + } + Ok(()) } fn enqueue(&self, source: &str, target: &str, prompt: &str) -> Result { - let conn = self.conn.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - conn.execute( - "INSERT INTO prompt_queue (source, target, prompt, created_at) VALUES (?, ?, ?, ?)", - params![source, target, prompt, now], - )?; - - let id = conn.last_insert_rowid(); - - // Capture event for recording playback - let _ = capture_message_event(&conn, source, target); - - Ok(id) + self.enqueue_full(source, target, prompt, None, None, None) } fn enqueue_with_session( @@ -208,33 +226,25 @@ impl PromptQueueStore for SqlitePromptQueueStore { prompt: &str, factory_session: &str, ) -> Result { - let conn = self.conn.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - conn.execute( - "INSERT INTO prompt_queue (source, target, prompt, created_at, factory_session) VALUES (?, ?, ?, ?, ?)", - params![source, target, prompt, now, factory_session], - )?; - - let id = conn.last_insert_rowid(); - let _ = capture_message_event(&conn, source, target); - Ok(id) + self.enqueue_full(source, target, prompt, Some(factory_session), None, None) } - fn enqueue_with_summary( + fn enqueue_full( &self, source: &str, target: &str, prompt: &str, factory_session: Option<&str>, summary: Option<&str>, + priority: Option, ) -> Result { let conn = self.conn.lock().unwrap(); let now = Utc::now().to_rfc3339(); + let prio: i32 = priority.unwrap_or(NotificationPriority::Normal).into(); conn.execute( - "INSERT INTO prompt_queue (source, target, prompt, created_at, factory_session, summary) VALUES (?, ?, ?, ?, ?, ?)", - params![source, target, prompt, now, factory_session, summary], + "INSERT INTO prompt_queue (source, target, prompt, created_at, factory_session, summary, priority) VALUES (?, ?, ?, ?, ?, ?, ?)", + params![source, target, prompt, now, factory_session, summary, prio], )?; let id = conn.last_insert_rowid(); @@ -248,10 +258,10 @@ impl PromptQueueStore for SqlitePromptQueueStore { // Get pending prompts for this target or "all_workers" let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority FROM prompt_queue WHERE (target = ? OR target = 'all_workers') AND processed_at IS NULL - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?", )?; @@ -287,10 +297,10 @@ impl PromptQueueStore for SqlitePromptQueueStore { let now = Utc::now().to_rfc3339(); let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority FROM prompt_queue WHERE processed_at IS NULL - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?", )?; @@ -325,10 +335,10 @@ impl PromptQueueStore for SqlitePromptQueueStore { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority FROM prompt_queue WHERE processed_at IS NULL - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?", )?; @@ -372,11 +382,11 @@ impl PromptQueueStore for SqlitePromptQueueStore { let where_clause = conditions.join(" OR "); let sql = format!( - "SELECT id, source, target, prompt, created_at, processed_at, summary + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority FROM prompt_queue WHERE processed_at IS NULL AND ({where_clause}) - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?" ); @@ -593,4 +603,98 @@ mod tests { .unwrap(); assert_eq!(by_session.len(), 1); // session match } + + #[test] + fn test_priority_ordering() { + let (_temp, store) = create_test_store(); + + // Enqueue in reverse priority order: normal first, then critical + store + .enqueue_full( + "supervisor", + "worker", + "Normal update", + None, + None, + Some(NotificationPriority::Normal), + ) + .unwrap(); + store + .enqueue_full( + "supervisor", + "worker", + "Critical blocker", + None, + None, + Some(NotificationPriority::Critical), + ) + .unwrap(); + store + .enqueue_full( + "supervisor", + "worker", + "High priority", + None, + None, + Some(NotificationPriority::High), + ) + .unwrap(); + + let prompts = store.peek_all(10).unwrap(); + assert_eq!(prompts.len(), 3); + // Critical (0) should come first, then High (1), then Normal (2) + assert_eq!(prompts[0].prompt, "Critical blocker"); + assert_eq!(prompts[0].priority, NotificationPriority::Critical); + assert_eq!(prompts[1].prompt, "High priority"); + assert_eq!(prompts[1].priority, NotificationPriority::High); + assert_eq!(prompts[2].prompt, "Normal update"); + assert_eq!(prompts[2].priority, NotificationPriority::Normal); + } + + #[test] + fn test_default_priority_is_normal() { + let (_temp, store) = create_test_store(); + + store + .enqueue("supervisor", "worker", "Default priority") + .unwrap(); + + let prompts = store.peek_all(10).unwrap(); + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0].priority, NotificationPriority::Normal); + } + + #[test] + fn test_priority_with_peek_for_targets() { + let (_temp, store) = create_test_store(); + + store + .enqueue_full( + "worker", + "supervisor", + "Status update", + Some("session-1"), + None, + Some(NotificationPriority::Normal), + ) + .unwrap(); + store + .enqueue_full( + "worker", + "supervisor", + "BLOCKED: need help", + Some("session-1"), + None, + Some(NotificationPriority::High), + ) + .unwrap(); + + let prompts = store + .peek_for_targets(&["supervisor"], Some("session-1"), 10) + .unwrap(); + assert_eq!(prompts.len(), 2); + // High priority should come first + assert_eq!(prompts[0].prompt, "BLOCKED: need help"); + assert_eq!(prompts[1].prompt, "Status update"); + } } From edbdd33cec21d5c18821cd8239e99afb531f4abb Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 11:14:46 -0400 Subject: [PATCH 18/22] feat: add delivery confirmation for prompt queue messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ack-back mechanism so senders can verify message delivery: - Schema: add `acked_at` column to prompt_queue table (safe migration) - Store: add `ack()`, `unacked()`, `message_status()` to PromptQueueStore trait - MessageStatus enum: Pending → Delivered → Confirmed lifecycle - Coordination: add `message_ack` and `message_status` actions - Tests: 5 new tests covering ack, idempotency, nonexistent, timeout, status Messages now have a 3-state lifecycle: 1. Pending - queued but not yet injected 2. Delivered - injected (processed_at set) but unconfirmed 3. Confirmed - target agent acked receipt (acked_at set) Agents can ack via: coordination action=message_ack notification_id= Senders can check via: coordination action=message_status notification_id= Unacked messages can be queried via store.unacked(timeout_secs, limit). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../service/agent_search_system/message.rs | 69 ++++++ cas-cli/src/mcp/tools/service/mod.rs | 9 +- crates/cas-mcp/src/types/ops_secondary.rs | 4 +- crates/cas-store/src/lib.rs | 4 +- crates/cas-store/src/prompt_queue_store.rs | 201 +++++++++++++++++- 5 files changed, 277 insertions(+), 10 deletions(-) diff --git a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs index 08d9f602d..f44a5461c 100644 --- a/cas-cli/src/mcp/tools/service/agent_search_system/message.rs +++ b/cas-cli/src/mcp/tools/service/agent_search_system/message.rs @@ -284,4 +284,73 @@ impl CasService { truncate_str(&message, 100) ))) } + + pub(in crate::mcp::tools::service) async fn message_ack( + &self, + req: AgentRequest, + ) -> Result { + use crate::store::open_prompt_queue_store; + + let notification_id = req.notification_id.ok_or_else(|| { + Self::error( + ErrorCode::INVALID_PARAMS, + "notification_id required for message_ack (the prompt queue message ID)", + ) + })?; + + let queue = open_prompt_queue_store(&self.inner.cas_root).map_err(|error| { + Self::error( + ErrorCode::INTERNAL_ERROR, + format!("Failed to open prompt queue: {error}"), + ) + })?; + + queue.ack(notification_id).map_err(|error| { + Self::error( + ErrorCode::INTERNAL_ERROR, + format!("Failed to acknowledge message: {error}"), + ) + })?; + + Ok(Self::success(format!( + "Message {notification_id} acknowledged (delivery confirmed)" + ))) + } + + pub(in crate::mcp::tools::service) async fn message_status_query( + &self, + req: AgentRequest, + ) -> Result { + use crate::store::open_prompt_queue_store; + + let notification_id = req.notification_id.ok_or_else(|| { + Self::error( + ErrorCode::INVALID_PARAMS, + "notification_id required for message_status (the prompt queue message ID)", + ) + })?; + + let queue = open_prompt_queue_store(&self.inner.cas_root).map_err(|error| { + Self::error( + ErrorCode::INTERNAL_ERROR, + format!("Failed to open prompt queue: {error}"), + ) + })?; + + let status = queue.message_status(notification_id).map_err(|error| { + Self::error( + ErrorCode::INTERNAL_ERROR, + format!("Failed to query message status: {error}"), + ) + })?; + + match status { + Some(s) => Ok(Self::success(format!( + "Message {notification_id} status: {s}" + ))), + None => Ok(Self::success(format!( + "Message {notification_id} not found" + ))), + } + } } diff --git a/cas-cli/src/mcp/tools/service/mod.rs b/cas-cli/src/mcp/tools/service/mod.rs index d6f442516..f2fa72a2a 100644 --- a/cas-cli/src/mcp/tools/service/mod.rs +++ b/cas-cli/src/mcp/tools/service/mod.rs @@ -416,7 +416,7 @@ impl CasService { // ======================================================================== #[tool( - description = "Coordination operations combining agent, factory, and worktree management. Agent actions: register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message. Factory actions: spawn_workers, shutdown_workers, worker_status, worker_activity, clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, remind, remind_list, remind_cancel. Worktree actions: worktree_create, worktree_list, worktree_show, worktree_cleanup, worktree_merge, worktree_status. Only available in factory mode. For shutdown_workers, supervisor should verify worktree cleanliness/policy before issuing shutdown." + description = "Coordination operations combining agent, factory, and worktree management. Agent actions: register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message, message_ack, message_status. Factory actions: spawn_workers, shutdown_workers, worker_status, worker_activity, clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, remind, remind_list, remind_cancel. Worktree actions: worktree_create, worktree_list, worktree_show, worktree_cleanup, worktree_merge, worktree_status. Only available in factory mode. For shutdown_workers, supervisor should verify worktree cleanliness/policy before issuing shutdown." )] pub async fn coordination( &self, @@ -428,7 +428,8 @@ impl CasService { // ---- Agent domain ---- "register" | "unregister" | "whoami" | "heartbeat" | "session_start" | "session_end" | "loop_start" | "loop_cancel" | "loop_status" | "lease_history" - | "queue_notify" | "queue_poll" | "queue_peek" | "queue_ack" | "message" => { + | "queue_notify" | "queue_poll" | "queue_peek" | "queue_ack" | "message" + | "message_ack" | "message_status" => { let agent_req = req.to_agent_request(&action); match action.as_str() { "register" => self.agent_register(agent_req).await, @@ -446,6 +447,8 @@ impl CasService { "queue_peek" => self.queue_peek(agent_req).await, "queue_ack" => self.queue_ack(agent_req).await, "message" => self.message_send(agent_req).await, + "message_ack" => self.message_ack(agent_req).await, + "message_status" => self.message_status_query(agent_req).await, _ => unreachable!(), } } @@ -531,7 +534,7 @@ impl CasService { ErrorCode::INVALID_PARAMS, format!( "Unknown coordination action: '{action}'. Valid actions:\n\ - Agent: register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message\n\ + Agent: register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message, message_ack, message_status\n\ Factory: spawn_workers, shutdown_workers, worker_status, worker_activity, clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, remind, remind_list, remind_cancel\n\ Worktree: worktree_create, worktree_list, worktree_show, worktree_cleanup, worktree_merge, worktree_status" ), diff --git a/crates/cas-mcp/src/types/ops_secondary.rs b/crates/cas-mcp/src/types/ops_secondary.rs index c00a6712a..981e5e9b7 100644 --- a/crates/cas-mcp/src/types/ops_secondary.rs +++ b/crates/cas-mcp/src/types/ops_secondary.rs @@ -449,7 +449,7 @@ pub struct FactoryRequest { /// /// Agent actions: register, unregister, whoami, heartbeat, agent_list, agent_cleanup, /// session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, -/// queue_notify, queue_poll, queue_peek, queue_ack, message. +/// queue_notify, queue_poll, queue_peek, queue_ack, message, message_ack, message_status. /// Factory actions: spawn_workers, shutdown_workers, worker_status, worker_activity, /// clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, /// remind, remind_list, remind_cancel. @@ -459,7 +459,7 @@ pub struct FactoryRequest { pub struct CoordinationRequest { /// Action to perform #[schemars( - description = "Action: agent ops (register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message), factory ops (spawn_workers, shutdown_workers, worker_status, worker_activity, clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, remind, remind_list, remind_cancel), worktree ops (worktree_create, worktree_list, worktree_show, worktree_cleanup, worktree_merge, worktree_status). Only available in factory mode. For shutdown_workers, supervisor should verify worktree cleanliness/policy before issuing shutdown." + description = "Action: agent ops (register, unregister, whoami, heartbeat, agent_list, agent_cleanup, session_start, session_end, loop_start, loop_cancel, loop_status, lease_history, queue_notify, queue_poll, queue_peek, queue_ack, message, message_ack, message_status), factory ops (spawn_workers, shutdown_workers, worker_status, worker_activity, clear_context, my_context, sync_all_workers, gc_report, gc_cleanup, remind, remind_list, remind_cancel), worktree ops (worktree_create, worktree_list, worktree_show, worktree_cleanup, worktree_merge, worktree_status). Only available in factory mode. For shutdown_workers, supervisor should verify worktree cleanliness/policy before issuing shutdown." )] pub action: String, diff --git a/crates/cas-store/src/lib.rs b/crates/cas-store/src/lib.rs index 2ad954903..864bda1ec 100644 --- a/crates/cas-store/src/lib.rs +++ b/crates/cas-store/src/lib.rs @@ -111,7 +111,9 @@ pub use supervisor_queue_store::{ }; // Prompt queue store for supervisor → worker communication -pub use prompt_queue_store::{PromptQueueStore, QueuedPrompt, SqlitePromptQueueStore}; +pub use prompt_queue_store::{ + MessageStatus, PromptQueueStore, QueuedPrompt, SqlitePromptQueueStore, +}; // Reminder store for supervisor "Remind Me" feature pub use reminder_store::{ diff --git a/crates/cas-store/src/prompt_queue_store.rs b/crates/cas-store/src/prompt_queue_store.rs index 38144210b..396e07031 100644 --- a/crates/cas-store/src/prompt_queue_store.rs +++ b/crates/cas-store/src/prompt_queue_store.rs @@ -32,6 +32,8 @@ pub struct QueuedPrompt { pub summary: Option, /// Message priority (lower = higher priority) pub priority: NotificationPriority, + /// When the target agent acknowledged receipt (None if not yet acked) + pub acked_at: Option>, } /// Schema for prompt queue table @@ -64,6 +66,32 @@ const PROMPT_QUEUE_PRIORITY_MIGRATION: &str = r#" ALTER TABLE prompt_queue ADD COLUMN priority INTEGER NOT NULL DEFAULT 2; "#; +/// Add acked_at column for delivery confirmation. +const PROMPT_QUEUE_ACKED_AT_MIGRATION: &str = r#" +ALTER TABLE prompt_queue ADD COLUMN acked_at TEXT; +"#; + +/// Delivery status of a prompt queue message +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum MessageStatus { + /// Message is queued but not yet delivered + Pending, + /// Message was injected/delivered but not yet acknowledged by the target + Delivered, + /// Target agent has confirmed receipt + Confirmed, +} + +impl std::fmt::Display for MessageStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Delivered => write!(f, "delivered"), + Self::Confirmed => write!(f, "confirmed"), + } + } +} + /// Trait for prompt queue operations pub trait PromptQueueStore: Send + Sync { /// Initialize the store (create tables) @@ -130,6 +158,15 @@ pub trait PromptQueueStore: Send + Sync { /// Mark a prompt as processed fn mark_processed(&self, prompt_id: i64) -> Result<()>; + /// Acknowledge receipt of a prompt (target agent confirms delivery) + fn ack(&self, prompt_id: i64) -> Result<()>; + + /// Get messages that were processed but not acked within the timeout + fn unacked(&self, timeout_secs: i64, limit: usize) -> Result>; + + /// Get delivery status of a specific message + fn message_status(&self, prompt_id: i64) -> Result>; + /// Get count of pending prompts fn pending_count(&self) -> Result; @@ -169,6 +206,8 @@ impl SqlitePromptQueueStore { let processed_at = processed_at_str.and_then(|s| Self::parse_datetime(&s)); let summary: Option = row.get(6).unwrap_or(None); let priority: u8 = row.get(7).unwrap_or(2); + let acked_at_str: Option = row.get(8).unwrap_or(None); + let acked_at = acked_at_str.and_then(|s| Self::parse_datetime(&s)); Ok(QueuedPrompt { id: row.get(0)?, @@ -179,6 +218,7 @@ impl SqlitePromptQueueStore { processed_at, summary, priority: NotificationPriority::from(priority), + acked_at, }) } } @@ -212,6 +252,14 @@ impl PromptQueueStore for SqlitePromptQueueStore { conn.execute_batch(PROMPT_QUEUE_PRIORITY_MIGRATION)?; } + // Add acked_at column if missing (delivery confirmation) + let has_acked_at_col = conn + .prepare("SELECT acked_at FROM prompt_queue LIMIT 0") + .is_ok(); + if !has_acked_at_col { + conn.execute_batch(PROMPT_QUEUE_ACKED_AT_MIGRATION)?; + } + Ok(()) } @@ -258,7 +306,7 @@ impl PromptQueueStore for SqlitePromptQueueStore { // Get pending prompts for this target or "all_workers" let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary, priority + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority, acked_at FROM prompt_queue WHERE (target = ? OR target = 'all_workers') AND processed_at IS NULL ORDER BY priority ASC, id ASC @@ -297,7 +345,7 @@ impl PromptQueueStore for SqlitePromptQueueStore { let now = Utc::now().to_rfc3339(); let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary, priority + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority, acked_at FROM prompt_queue WHERE processed_at IS NULL ORDER BY priority ASC, id ASC @@ -335,7 +383,7 @@ impl PromptQueueStore for SqlitePromptQueueStore { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, source, target, prompt, created_at, processed_at, summary, priority + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority, acked_at FROM prompt_queue WHERE processed_at IS NULL ORDER BY priority ASC, id ASC @@ -382,7 +430,7 @@ impl PromptQueueStore for SqlitePromptQueueStore { let where_clause = conditions.join(" OR "); let sql = format!( - "SELECT id, source, target, prompt, created_at, processed_at, summary, priority + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority, acked_at FROM prompt_queue WHERE processed_at IS NULL AND ({where_clause}) @@ -415,6 +463,76 @@ impl PromptQueueStore for SqlitePromptQueueStore { Ok(()) } + fn ack(&self, prompt_id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let rows = conn.execute( + "UPDATE prompt_queue SET acked_at = ? WHERE id = ? AND acked_at IS NULL", + params![now, prompt_id], + )?; + + if rows == 0 { + // Check if the prompt exists at all + let exists: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM prompt_queue WHERE id = ?", + params![prompt_id], + |row| row.get(0), + )?; + if !exists { + return Err(crate::StoreError::NotFound(format!( + "Prompt {prompt_id} not found" + ))); + } + // Already acked — idempotent, not an error + } + + Ok(()) + } + + fn unacked(&self, timeout_secs: i64, limit: usize) -> Result> { + let conn = self.conn.lock().unwrap(); + let cutoff = (Utc::now() - chrono::Duration::seconds(timeout_secs)).to_rfc3339(); + + let mut stmt = conn.prepare( + "SELECT id, source, target, prompt, created_at, processed_at, summary, priority, acked_at + FROM prompt_queue + WHERE processed_at IS NOT NULL + AND processed_at < ? + AND acked_at IS NULL + ORDER BY priority ASC, id ASC + LIMIT ?", + )?; + + let prompts = stmt + .query_map(params![cutoff, limit as i64], Self::prompt_from_row)? + .collect::, _>>()?; + + Ok(prompts) + } + + fn message_status(&self, prompt_id: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + + let result = conn.query_row( + "SELECT processed_at, acked_at FROM prompt_queue WHERE id = ?", + params![prompt_id], + |row| { + let processed_at: Option = row.get(0)?; + let acked_at: Option = row.get(1)?; + Ok((processed_at, acked_at)) + }, + ); + + match result { + Ok((_, Some(_))) => Ok(Some(MessageStatus::Confirmed)), + Ok((Some(_), None)) => Ok(Some(MessageStatus::Delivered)), + Ok((None, _)) => Ok(Some(MessageStatus::Pending)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + fn pending_count(&self) -> Result { let conn = self.conn.lock().unwrap(); @@ -697,4 +815,79 @@ mod tests { assert_eq!(prompts[0].prompt, "BLOCKED: need help"); assert_eq!(prompts[1].prompt, "Status update"); } + + #[test] + fn test_ack_delivery_confirmation() { + let (_temp, store) = create_test_store(); + + let id = store.enqueue("supervisor", "worker-1", "Do task").unwrap(); + + // Initially pending + let status = store.message_status(id).unwrap(); + assert_eq!(status, Some(MessageStatus::Pending)); + + // Mark as processed (delivered) + store.mark_processed(id).unwrap(); + let status = store.message_status(id).unwrap(); + assert_eq!(status, Some(MessageStatus::Delivered)); + + // Ack (confirmed) + store.ack(id).unwrap(); + let status = store.message_status(id).unwrap(); + assert_eq!(status, Some(MessageStatus::Confirmed)); + + // Ack is idempotent + store.ack(id).unwrap(); + + // Peek shows acked_at is set + let prompts = store.poll_for_target("worker-1", 10).unwrap(); + assert!(prompts.is_empty()); // already processed + } + + #[test] + fn test_ack_nonexistent_returns_error() { + let (_temp, store) = create_test_store(); + let result = store.ack(99999); + assert!(result.is_err()); + } + + #[test] + fn test_message_status_nonexistent() { + let (_temp, store) = create_test_store(); + let status = store.message_status(99999).unwrap(); + assert_eq!(status, None); + } + + #[test] + fn test_unacked_timeout() { + let (_temp, store) = create_test_store(); + + let id1 = store.enqueue("supervisor", "worker-1", "Msg 1").unwrap(); + let id2 = store.enqueue("supervisor", "worker-2", "Msg 2").unwrap(); + + // Process both + store.mark_processed(id1).unwrap(); + store.mark_processed(id2).unwrap(); + + // Ack only one + store.ack(id2).unwrap(); + + // With timeout=0, all delivered-but-unacked messages should appear + let unacked = store.unacked(0, 10).unwrap(); + assert_eq!(unacked.len(), 1); + assert_eq!(unacked[0].id, id1); + assert_eq!(unacked[0].prompt, "Msg 1"); + } + + #[test] + fn test_unacked_respects_timeout() { + let (_temp, store) = create_test_store(); + + let id = store.enqueue("supervisor", "worker-1", "Recent").unwrap(); + store.mark_processed(id).unwrap(); + + // With a large timeout, the recently processed message should NOT appear + let unacked = store.unacked(3600, 10).unwrap(); + assert!(unacked.is_empty()); + } } From 3db43216010ce5ee4ff4009cd463f1703c367e95 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 11:32:48 -0400 Subject: [PATCH 19/22] chore: update CLAUDE.md with detailed architecture docs and add MCP tool permissions - CLAUDE.md: replace abbreviated docs with comprehensive architecture guide covering workspace layout, key patterns, testing, and migration details - settings.json: add CAS MCP tool permissions to allow list Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 12 +- CLAUDE.md | 306 ++++++++++++------------------------------ 2 files changed, 100 insertions(+), 218 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 9cf4f5823..83b28251d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -145,7 +145,17 @@ "Bash(cas :*)", "Bash(cas task:*)", "Bash(cas search:*)", - "Bash(cas add:*)" + "Bash(cas add:*)", + "mcp__cas__task", + "mcp__cas__coordination", + "mcp__cas__memory", + "mcp__cas__search", + "mcp__cas__rule", + "mcp__cas__skill", + "mcp__cas__spec", + "mcp__cas__verification", + "mcp__cas__system", + "mcp__cas__pattern" ] }, "statusLine": { diff --git a/CLAUDE.md b/CLAUDE.md index fecddd165..c01faf2f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,6 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. # IMPORTANT: USE CAS FOR TASK AND MEMORY MANAGEMENT @@ -13,258 +16,127 @@ Use CAS MCP tools instead: CAS provides persistent context across sessions. Built-in tools are ephemeral. -# CAS - Coding Agent System - -Unified context system for AI agents: persistent memory, tasks, rules, and skills across sessions. - -**Design Philosophy**: CAS is built for AI agents as the primary users. Humans are observers who review agent activity, provide feedback, and guide direction - but the tools and workflows are optimized for agent consumption and production. - -## Use CAS for Task & Memory Management - -**Agents use CAS MCP tools instead of built-in TodoWrite:** - -``` -mcp__cas__task action=create - Create/track tasks -mcp__cas__task action=start - Start working on a task (sets status to in_progress) -mcp__cas__task action=notes - Add progress notes (progress, blocker, decision, discovery) -mcp__cas__task action=close - Complete tasks -mcp__cas__memory action=remember - Store learnings and context -mcp__cas__search action=search - Find relevant context (filter with doc_type: entry/task/rule/skill) -``` - -**Human CLI:** -```bash -cas init # Initialize CAS in a project -cas serve # Start MCP server -cas config list # View configuration -cas doctor # Run diagnostics -cas update # Self-update -``` - -## Project Structure - -``` -cas-cli/ # Rust CLI & MCP server (primary) -crates/ # Workspace crates (cas-core, cas-store, cas-search, etc.) -``` - -### cas-cli Architecture - -| Directory | Purpose | -|-----------|---------| -| `src/types/` | Core data: Entry, Task, Rule, Skill | -| `src/store/` | Storage abstraction (SqliteStore primary) | -| `src/search/` | Full-text search (BM25 via Tantivy) | -| `src/cli/` | Command handlers | -| `src/hooks/` | Claude Code integration | - -## Key Patterns - -**Store Trait** (`cas-cli/src/store/mod.rs`): All storage ops go through trait abstractions. SqliteStore is primary. - -**Rule Auto-Promotion**: Use `mcp__cas__rule action=helpful id=` to promote Draft/Stale rules to Proven, auto-syncs to `.claude/rules/`. - -**MCP Server**: `cas serve` exposes all functionality as MCP tools for Claude Code integration. - -## Skill Frontmatter Fields (Claude Code 2.1.3+) - -CAS skills sync to `.claude/skills/` as SKILL.md files with YAML frontmatter. - -**Note:** In Claude Code 2.1.3, skills and commands were unified into a single system. - -The following fields are supported: - -### Required Fields - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Skill name (prefixed with `cas-`) | -| `description` | string | 1-2 line description | +## What is CAS -### Optional Fields +CAS (Coding Agent System) is a multi-agent coding factory and persistent context system for AI agents. Written in Rust. -| Field | Type | Description | Example | -|-------|------|-------------|---------| -| `user-invocable` | bool | Hide from slash menu (model can still invoke) | `false` | -| `argument-hint` | string | Hint shown when invoked (must be quoted) | `"[query]"` | -| `context` | string | Execution context mode | `fork` | -| `agent` | string | Specialized agent to use | `Explore`, `code-reviewer` | -| `allowed-tools` | list | Restrict tools the skill can use | `- Read`
`- Grep` | -| `disable-model-invocation` | bool | Block model from invoking (command-only) | `true` | -| `hooks` | object | Skill-scoped hooks (Claude Code 2.1.0+) | See below | +Two core capabilities: +1. **Factory** — Terminal UI orchestrating multiple Claude Code instances in parallel via isolated git worktrees, with supervisor/worker coordination. +2. **Context System** — MCP server providing persistent memory, tasks, rules, skills, and search (55+ tools) backed by SQLite + Tantivy BM25 search. -**Note:** `user-invocable: false` only hides the skill from the user's slash menu. The model can still invoke the skill unless `disable-model-invocation: true` is also set. - -### Hooks Structure (Claude Code 2.1.0+) - -```yaml -hooks: - PreToolUse: - - matcher: "Write|Edit" - hooks: - - type: command - command: cas hook PreToolUse - timeout: 3000 - PostToolUse: - - matcher: "Write|Edit" - hooks: - - type: command - command: cas hook PostToolUse - timeout: 3000 - Stop: - - hooks: - - type: command - command: cas hook Stop -``` - -### Example SKILL.md - -```yaml ---- -name: cas-deep-search -description: Comprehensive codebase search using forked context -argument-hint: "[query]" -context: fork -agent: Explore -allowed-tools: - - Read - - Grep - - Glob ---- - -# Deep Search - -Instructions for the skill... -``` - -### Creating Skills - -**Via MCP (for agents):** -``` -mcp__cas__skill action=create name="My Skill" invokable=true argument_hint="[args]" -``` +## Build & Test -**Via CLI (for humans):** ```bash -cas skill create "My Skill" --invokable --argument-hint "[args]" -``` +# Build (from repo root or cas-cli/) +cargo build # Dev build +cargo build --release # Release build (LTO, strip) +cargo build --profile release-fast # Fast release (thin LTO, 16 codegen units) -Skills are automatically synced to `.claude/skills/` when enabled. - -## Hook Configuration (Claude Code 2.1.0+) +# Run all tests +cargo test -### once: true Support +# Run a single test by name +cargo test test_name -Claude Code 2.1.0 adds `once: true` for hooks that should only execute once per session (even on resume). CAS hooks intentionally do NOT use this because: +# Run tests in a specific file +cargo test --test cli_test -- **SessionStart**: Should inject context on every start/resume -- **PostToolUse/Stop**: Should run on every matching event +# Run tests matching a pattern +cargo test migration -To add `once: true` to a specific hook, manually edit `.claude/settings.json`: +# Run benchmarks +cargo bench --bench code_indexing -```json -{ - "hooks": { - "SessionStart": [{ - "hooks": [{ - "type": "command", - "command": "cas hook SessionStart", - "timeout": 5000, - "once": true - }] - }] - } -} +# Run with specific feature +cargo test --features claude_rs_e2e ``` -### Bash Wildcard Permissions +The `mcp-server` feature is enabled by default. The binary is `cas` (both lib and bin targets in `cas-cli/`). -Claude Code 2.1.0 supports wildcard patterns for Bash permissions. Add to `.claude/settings.json`: +The build script (`cas-cli/build.rs`) embeds git hash and build date into the binary, and loads telemetry keys from `.env` if present. -```json -{ - "permissions": { - "allow": [ - "Bash(cas :*)", - "Bash(cas task:*)", - "Bash(cas search:*)" - ] - } -} -``` +## Architecture -Patterns: -- `Bash(cas :*)` - Allow all CAS commands -- `Bash(cas task:*)` - Allow task operations only -- `Bash(:* --help)` - Allow help for any command +### Workspace Layout -## Code Search with ast-grep +The root `Cargo.toml` defines a workspace. `cas-cli/` is the main binary crate; `crates/` contains library crates. -Use `ast-grep` for syntax-aware structural matching instead of text-only tools like `rg` or `grep`. This is especially useful for finding code patterns, refactoring, and understanding structure. +**Core data flow**: CLI commands and MCP tool calls both go through the store trait abstractions in `cas-cli/src/store/`, which wraps `cas-store` (SQLite) with notification and sync layers. -```bash -# Rust: Find all function definitions -ast-grep --lang rust -p 'fn $NAME($$$) { $$$ }' +### cas-cli (main crate) — `cas-cli/src/` -# Rust: Find impl blocks for a trait -ast-grep --lang rust -p 'impl $TRAIT for $TYPE { $$$ }' +| Module | Purpose | +|--------|---------| +| `main.rs` / `lib.rs` | Entry point, module declarations | +| `cli/` | Clap command definitions and handlers. `mod.rs` has the `Commands` enum — add new subcommands here. | +| `mcp/` | MCP server: `server/` (CasCore with cached OnceLock stores), `tools/` (55 tool handlers split into `core/` and `service/`), `daemon.rs` (embedded background maintenance), `socket.rs` (notification socket) | +| `store/` | Re-exports from `cas-store` + wrappers: `notifying_*.rs` (emit change notifications), `syncing_*.rs` (sync to `.claude/` filesystem), `layered.rs` (project + global store composition), `detect.rs` (find `.cas/` root) | +| `hooks/` | Claude Code hook event handlers (SessionStart, Stop, PostToolUse, etc.). `handlers/` has session, state, event, and middleware handlers. `scorer.rs` ranks context items for injection. | +| `migration/` | Forward-only schema migrations. `migrations/` has individual migration files (m001-m182+). `detector.rs` introspects existing schema. | +| `ui/` | Ratatui TUI components for factory view: `factory/`, `components/`, `widgets/`, `theme/`, `markdown/` | +| `config/` | Configuration loading from `.cas/config.yaml` | +| `orchestration/` | Agent name generation and orchestration logic | +| `worktree/` | Git worktree management for factory workers | +| `consolidation/` | Memory consolidation and decay | +| `extraction/` | AI-powered extraction of observations into structured memory | +| `bridge/` | Local helper server for external tool integration | +| `cloud/` | CAS Cloud sync (optional) | +| `sync/` | Filesystem sync to `.claude/rules/` and `.claude/skills/` | -# Rust: Find unwrap() calls (potential error handling issues) -ast-grep --lang rust -p '$EXPR.unwrap()' +### Workspace Crates — `crates/` -# Elixir: Find function definitions -ast-grep --lang elixir -p 'def $NAME($$$) do $$$ end' +| Crate | Purpose | +|-------|---------| +| `cas-types` | Shared data types (Entry, Task, Rule, Skill, Agent, etc.) | +| `cas-store` | SQLite storage layer — trait definitions (`Store`, `TaskStore`, `RuleStore`, etc.) and `SqliteStore` implementation | +| `cas-search` | Full-text search via Tantivy (BM25 scoring) | +| `cas-core` | Core business logic, hooks framework, search index abstraction, skill/rule syncing | +| `cas-mcp` | MCP protocol types and request/response models | +| `cas-factory` | Factory session lifecycle: `FactoryCore`, config, director, recording, notifications | +| `cas-factory-protocol` | WebSocket message protocol between supervisor and worker agents | +| `cas-mux` | Terminal multiplexer layout and rendering (side-by-side/tabbed agent views) | +| `cas-pty` | PTY management for agent terminal sessions | +| `cas-recording` | Terminal session recording and playback | +| `cas-code` | Code analysis via tree-sitter | +| `cas-diffs` | Diff parsing, rendering, syntax highlighting | +| `cas-tui-test` | TUI testing framework | +| `ghostty_vt` / `ghostty_vt_sys` | Virtual terminal parser (based on Ghostty) | -# Elixir: Find pipe chains -ast-grep --lang elixir -p '$EXPR |> $FUNC($$$)' +### Key Patterns -# TypeScript: Find React component definitions -ast-grep --lang typescript -p 'function $NAME($$$): JSX.Element { $$$ }' +**Store trait hierarchy**: `cas-store` defines traits (`Store`, `TaskStore`, `RuleStore`, `SkillStore`, `EntityStore`, `AgentStore`, `VerificationStore`, `WorktreeStore`). `SqliteStore` implements all of them. `cas-cli/src/store/` wraps these with notification and sync decorators. -# TypeScript: Find useState hooks -ast-grep --lang typescript -p 'const [$STATE, $SETTER] = useState($$$)' -``` +**CasCore (MCP server)**: Lives in `cas-cli/src/mcp/server/mod.rs`. Caches all store instances in `OnceLock` fields — each store type opened exactly once per server lifetime. Has an embedded daemon for background maintenance (embedding generation every 2min, full maintenance every 30min). -Only fall back to `rg` or `grep` for plain-text searches (comments, strings, config files) or when explicitly requested. +**CasContext**: In `cas-cli/src/store/mod.rs`. Resolves the `.cas/` directory once at CLI entry points and passes it through — enables deterministic test behavior. -## Build & Test +**Hook scoring**: `cas-cli/src/hooks/scorer.rs` ranks context items (memories, tasks, rules, skills) by relevance for injection into SessionStart context, staying within a token budget. -```bash -# cas-cli (Rust) -cd cas-cli && cargo build --release -cargo test +## Adding Features -``` +**New CLI command**: Add variant to `Commands` enum in `cas-cli/src/cli/mod.rs`, create handler file in `cli/`, add integration test in `tests/cli_test.rs`. -## Adding Features +**New MCP tool**: Add handler in `cas-cli/src/mcp/tools/core/` (data tools) or `cas-cli/src/mcp/tools/service/` (orchestration tools). Request types go in `cas-cli/src/mcp/tools/types/`. Register in the tool list via the `CasService` impl. -**New CLI command**: Add to `cas-cli/src/cli/mod.rs` Commands enum, create handler file, add integration test in `tests/cli_test.rs`. +**New migration**: Create file in `cas-cli/src/migration/migrations/` following naming convention `m{NNN}_{table}_{description}.rs`. Add to the `MIGRATIONS` array in `migrations/mod.rs`. Each migration needs: unique sequential ID, up SQL, and a detect query. See `cas-cli/docs/MIGRATIONS.md` for full details. Migration ID ranges: Entries 1-50, Rules 51-70, Skills 71-90, Agents 91-110, Entities/Worktrees 111+, Verification 131+, Loops/Events 151+. -**New MCP tool**: Add handler in `cas-cli/src/mcp/`, register in tool list. +## Testing -## Schema Migrations +Integration tests are in `cas-cli/tests/`. Key test files: +- `cli_test.rs` — CLI command integration tests +- `mcp_tools_test.rs` — MCP tool handler tests +- `mcp_protocol_test.rs` — MCP protocol compliance +- `factory_server_test.rs` — Factory WebSocket server tests +- `distributed_factory_test.rs` — Multi-agent factory tests +- `proptest_test.rs` — Property-based tests +- `e2e_test.rs` / `e2e/` — End-to-end tests -CAS uses a versioned migration system for database schema changes. See `cas-cli/docs/MIGRATIONS.md` for full documentation. +Dev dependencies include: `insta` (snapshot testing), `wiremock` (HTTP mocking), `rstest` (parametrized tests), `proptest` (property-based), `criterion` (benchmarks), `cas-tui-test` (TUI testing). -### Quick Reference +## Rust Version -**Adding a new column:** -1. Add column to base schema in `src/store/sqlite.rs` or `src/store/skill_store.rs` -2. Add migration in `src/migration/migrations.rs` with detection query -3. Run `cargo test migration` +Minimum supported Rust version: **1.85** (edition 2024). -**Migration ID ranges:** -| Subsystem | Range | Tables | -|-----------|-------|--------| -| Entries | 1-50 | entries, sessions | -| Rules | 51-70 | rules | -| Skills | 71-90 | skills | -| Agents | 91-110 | agents, task_leases | +## Skill & Rule Sync -**Human CLI commands:** -```bash -cas update --check # Check for pending migrations -cas update --dry-run # Preview migrations -cas update --schema-only # Apply migrations -cas doctor # Shows schema version -``` +CAS auto-syncs rules to `.claude/rules/` and skills to `.claude/skills/` as SKILL.md files with YAML frontmatter. The sync logic lives in `cas-cli/src/sync/`. Rule promotion: Draft → Proven via `mcp__cas__rule action=helpful`. From e4e8e3c0c899c434d80cccff24d2006b36747b90 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Sat, 2 May 2026 15:41:53 -0400 Subject: [PATCH 20/22] test(cas-63d9): add failing tests for reasoning_effort threading through PtyConfig::claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-first gate: new tests verify that: - custom effort value (e.g. "low") is used when provided - supervisor defaults to "high" when effort=None - worker defaults to "medium" when effort=None These tests require the new `effort: Option<&str>` parameter on PtyConfig::claude which does not exist yet — compilation failure is the expected failing state. Co-Authored-By: Claude Sonnet 4.6 --- crates/cas-pty/src/pty.rs | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/cas-pty/src/pty.rs b/crates/cas-pty/src/pty.rs index 9e642ab6a..bb9390bfc 100644 --- a/crates/cas-pty/src/pty.rs +++ b/crates/cas-pty/src/pty.rs @@ -826,6 +826,74 @@ mod tests { ); } + #[tokio::test] + async fn test_pty_config_claude_custom_effort() { + let config = PtyConfig::claude( + "test-agent", + "worker", + PathBuf::from("/tmp"), + None, + None, + None, + None, // model + Some("low"), // effort override + None, // teams + ); + let effort_idx = config + .args + .iter() + .position(|a| a == "--effort") + .expect("--effort must be present"); + assert_eq!( + config.args[effort_idx + 1], "low", + "custom effort should override hardcoded default" + ); + } + + #[tokio::test] + async fn test_pty_config_claude_supervisor_default_effort() { + // When effort is None and role is supervisor, must default to "high" + let config = PtyConfig::claude( + "sup", + "supervisor", + PathBuf::from("/tmp"), + None, + None, + None, + None, // model + None, // no effort — should fall back to "high" + None, // teams + ); + let effort_idx = config + .args + .iter() + .position(|a| a == "--effort") + .expect("--effort must be present"); + assert_eq!(config.args[effort_idx + 1], "high"); + } + + #[tokio::test] + async fn test_pty_config_claude_worker_default_effort() { + // When effort is None and role is worker, must default to "medium" + let config = PtyConfig::claude( + "wrk", + "worker", + PathBuf::from("/tmp"), + None, + None, + None, + None, // model + None, // no effort — should fall back to "medium" + None, // teams + ); + let effort_idx = config + .args + .iter() + .position(|a| a == "--effort") + .expect("--effort must be present"); + assert_eq!(config.args[effort_idx + 1], "medium"); + } + #[tokio::test] async fn test_pty_config_claude_with_teams_lead() { let teams = TeamsSpawnConfig { From ee652213a9a7eb07ad343466bac818643a22684d Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Sat, 2 May 2026 15:51:33 -0400 Subject: [PATCH 21/22] feat(cas-63d9): wire per-role reasoning_effort from LlmConfig through factory spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads `llm.supervisor.reasoning_effort` / `llm.worker.reasoning_effort` from `.cas/config.yaml` and threads the value all the way to the `--effort` flag passed to Claude CLI at spawn time. Changes: - cas-pty: add `effort: Option<&str>` to PtyConfig::claude and PtyConfig::codex; replace hardcoded high/medium with role-based fallback that respects the config value when set (backward-compatible: None → "high" for supervisor, "medium" for worker) - cas-mux: add `effort` param to Pane::worker() and Pane::supervisor(); add supervisor_effort/worker_effort to MuxConfig and Mux, wire through factory() and add_worker() - cas-factory: add supervisor_effort/worker_effort fields to FactoryConfig; wire into FactoryCore::new() (set_worker_effort) and spawn_supervisor() - cas-cli: populate the new FactoryConfig fields via llm.reasoning_effort_for_role() in both factory entry points (mod.rs + daemon.rs) and propagate through the two MuxConfig construction sites (init.rs + fork_first.rs) Also fixes pre-existing incorrect test assertion in test_pty_config_claude_with_teams which claimed workers should NOT have --session-id (they always do). Co-Authored-By: Claude Sonnet 4.6 --- cas-cli/src/cli/factory/daemon.rs | 2 + cas-cli/src/cli/factory/mod.rs | 2 + cas-cli/src/ui/factory/app/init.rs | 2 + cas-cli/src/ui/factory/daemon/fork_first.rs | 2 + crates/cas-factory/src/config.rs | 6 ++ crates/cas-factory/src/core.rs | 4 ++ .../cas-factory/tests/factory_integration.rs | 2 + crates/cas-mux/examples/factory.rs | 2 + crates/cas-mux/src/mux.rs | 18 +++++ crates/cas-mux/src/pane/mod.rs | 7 +- crates/cas-pty/src/pty.rs | 67 +++++++++++-------- 11 files changed, 85 insertions(+), 29 deletions(-) diff --git a/cas-cli/src/cli/factory/daemon.rs b/cas-cli/src/cli/factory/daemon.rs index 3e8f9e363..9367899d0 100644 --- a/cas-cli/src/cli/factory/daemon.rs +++ b/cas-cli/src/cli/factory/daemon.rs @@ -52,6 +52,8 @@ pub(super) fn execute_daemon( worker_cli, supervisor_model: llm.model_for_role("supervisor").map(String::from), worker_model: llm.model_for_role("worker").map(String::from), + supervisor_effort: llm.reasoning_effort_for_role("supervisor").map(String::from), + worker_effort: llm.reasoning_effort_for_role("worker").map(String::from), enable_worktrees: !no_worktrees, worktree_root, notify: NotifyConfig { diff --git a/cas-cli/src/cli/factory/mod.rs b/cas-cli/src/cli/factory/mod.rs index 4268f1365..a3f64f50a 100644 --- a/cas-cli/src/cli/factory/mod.rs +++ b/cas-cli/src/cli/factory/mod.rs @@ -618,6 +618,8 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path> worker_cli: preflight.worker_cli, supervisor_model: llm.model_for_role("supervisor").map(String::from), worker_model: llm.model_for_role("worker").map(String::from), + supervisor_effort: llm.reasoning_effort_for_role("supervisor").map(String::from), + worker_effort: llm.reasoning_effort_for_role("worker").map(String::from), enable_worktrees: preflight.enable_worktrees, worktree_root: args.worktree_root.clone(), notify: notify_config, diff --git a/cas-cli/src/ui/factory/app/init.rs b/cas-cli/src/ui/factory/app/init.rs index 0ee0cae09..9ec281b40 100644 --- a/cas-cli/src/ui/factory/app/init.rs +++ b/cas-cli/src/ui/factory/app/init.rs @@ -124,6 +124,8 @@ impl FactoryApp { worker_cli: config.worker_cli, supervisor_model: config.supervisor_model.clone(), worker_model: config.worker_model.clone(), + supervisor_effort: config.supervisor_effort.clone(), + worker_effort: config.worker_effort.clone(), include_director: false, rows, cols, diff --git a/cas-cli/src/ui/factory/daemon/fork_first.rs b/cas-cli/src/ui/factory/daemon/fork_first.rs index 7e7d118b2..894f2e7ff 100644 --- a/cas-cli/src/ui/factory/daemon/fork_first.rs +++ b/cas-cli/src/ui/factory/daemon/fork_first.rs @@ -330,6 +330,8 @@ impl DaemonInitPhase { worker_cli: self.factory_config.worker_cli, supervisor_model: self.factory_config.supervisor_model.clone(), worker_model: self.factory_config.worker_model.clone(), + supervisor_effort: self.factory_config.supervisor_effort.clone(), + worker_effort: self.factory_config.worker_effort.clone(), include_director: false, rows, cols, diff --git a/crates/cas-factory/src/config.rs b/crates/cas-factory/src/config.rs index 98b63265e..d5db238a2 100644 --- a/crates/cas-factory/src/config.rs +++ b/crates/cas-factory/src/config.rs @@ -169,6 +169,10 @@ pub struct FactoryConfig { pub supervisor_model: Option, /// Model for workers (passed as --model flag to CLI) pub worker_model: Option, + /// Reasoning effort for supervisor (passed as --effort flag; defaults to "high") + pub supervisor_effort: Option, + /// Reasoning effort for workers (passed as --effort flag; defaults to "medium") + pub worker_effort: Option, /// Enable worktree-based worker isolation (default: true) pub enable_worktrees: bool, /// Directory where worker worktrees are created (default: ../cas-worktrees) @@ -204,6 +208,8 @@ impl Default for FactoryConfig { worker_cli: cas_mux::SupervisorCli::Claude, supervisor_model: None, worker_model: None, + supervisor_effort: None, + worker_effort: None, enable_worktrees: true, worktree_root: None, notify: NotifyConfig::default(), diff --git a/crates/cas-factory/src/core.rs b/crates/cas-factory/src/core.rs index 7f7d38f29..021f5072c 100644 --- a/crates/cas-factory/src/core.rs +++ b/crates/cas-factory/src/core.rs @@ -134,6 +134,7 @@ impl FactoryCore { // Create mux with no panes initially let mut mux = Mux::new(rows, cols); mux.set_worker_cli(config.worker_cli); + mux.set_worker_effort(config.worker_effort.clone()); // Initialize recording if enabled let recording = if config.record { @@ -203,6 +204,7 @@ impl FactoryCore { self.config.worker_cli, &self.worker_names, self.config.supervisor_model.as_deref(), + self.config.supervisor_effort.as_deref(), None, // teams: cas-factory doesn't use native Agent Teams yet ) .map_err(|e| FactoryError::Mux(e.to_string()))?; @@ -461,6 +463,8 @@ mod tests { worker_cli: cas_mux::SupervisorCli::Claude, supervisor_model: None, worker_model: None, + supervisor_effort: None, + worker_effort: None, enable_worktrees: false, worktree_root: None, notify: Default::default(), diff --git a/crates/cas-factory/tests/factory_integration.rs b/crates/cas-factory/tests/factory_integration.rs index ce46efe87..ae89b23d5 100644 --- a/crates/cas-factory/tests/factory_integration.rs +++ b/crates/cas-factory/tests/factory_integration.rs @@ -186,6 +186,8 @@ fn test_config() -> FactoryConfig { worker_cli: SupervisorCli::Claude, supervisor_model: None, worker_model: None, + supervisor_effort: None, + worker_effort: None, enable_worktrees: false, worktree_root: None, notify: NotifyConfig::default(), diff --git a/crates/cas-mux/examples/factory.rs b/crates/cas-mux/examples/factory.rs index 722fdb0db..c53511e8a 100644 --- a/crates/cas-mux/examples/factory.rs +++ b/crates/cas-mux/examples/factory.rs @@ -33,6 +33,8 @@ async fn main() -> anyhow::Result<()> { worker_cli: SupervisorCli::Claude, supervisor_model: None, worker_model: None, + supervisor_effort: None, + worker_effort: None, include_director: false, // No director for this demo rows: 24, cols: 120, diff --git a/crates/cas-mux/src/mux.rs b/crates/cas-mux/src/mux.rs index 3da8d6172..56dc56b9b 100644 --- a/crates/cas-mux/src/mux.rs +++ b/crates/cas-mux/src/mux.rs @@ -37,6 +37,10 @@ pub struct MuxConfig { pub supervisor_model: Option, /// Model for workers (passed as --model flag) pub worker_model: Option, + /// Reasoning effort for supervisor (passed as --effort flag; defaults to "high") + pub supervisor_effort: Option, + /// Reasoning effort for workers (passed as --effort flag; defaults to "medium") + pub worker_effort: Option, /// Include director pane pub include_director: bool, /// Terminal size @@ -60,6 +64,8 @@ impl Default for MuxConfig { worker_cli: SupervisorCli::Claude, supervisor_model: None, worker_model: None, + supervisor_effort: None, + worker_effort: None, include_director: true, rows: 24, cols: 80, @@ -107,6 +113,8 @@ pub struct Mux { worker_cli: SupervisorCli, /// Worker model used for dynamic worker spawns worker_model: Option, + /// Worker reasoning effort used for dynamic worker spawns + worker_effort: Option, } impl Mux { @@ -122,6 +130,7 @@ impl Mux { cols, worker_cli: SupervisorCli::Claude, worker_model: None, + worker_effort: None, } } @@ -135,11 +144,17 @@ impl Mux { self.worker_model = model; } + /// Set reasoning effort used for worker pane spawns. + pub fn set_worker_effort(&mut self, effort: Option) { + self.worker_effort = effort; + } + /// Create a multiplexer with factory configuration pub fn factory(config: MuxConfig) -> Result { let mut mux = Self::new(config.rows, config.cols); mux.set_worker_cli(config.worker_cli); mux.set_worker_model(config.worker_model.clone()); + mux.set_worker_effort(config.worker_effort.clone()); // Calculate pane sizes based on layout // Layout: [Workers] [Supervisor] [Director] @@ -171,6 +186,7 @@ impl Mux { &config.supervisor_name, config.worker_cli, config.worker_model.as_deref(), + config.worker_effort.as_deref(), pane_rows, pane_cols, teams, @@ -190,6 +206,7 @@ impl Mux { config.worker_cli, &worker_names, config.supervisor_model.as_deref(), + config.supervisor_effort.as_deref(), sup_teams, )?; mux.add_pane(supervisor); @@ -413,6 +430,7 @@ impl Mux { supervisor_name, self.worker_cli, self.worker_model.as_deref(), + self.worker_effort.as_deref(), self.rows, self.cols, teams, diff --git a/crates/cas-mux/src/pane/mod.rs b/crates/cas-mux/src/pane/mod.rs index 76e0c7b3e..2532068ba 100644 --- a/crates/cas-mux/src/pane/mod.rs +++ b/crates/cas-mux/src/pane/mod.rs @@ -182,7 +182,6 @@ impl Pane { Self::with_pty(name, PaneKind::Shell, pty, rows, cols) } - #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] pub fn worker( name: &str, @@ -191,6 +190,7 @@ impl Pane { supervisor_name: &str, cli: SupervisorCli, model: Option<&str>, + effort: Option<&str>, rows: u16, cols: u16, teams: Option<&TeamsSpawnConfig>, @@ -205,6 +205,7 @@ impl Pane { Some(supervisor_name), None, model, + effort, teams, ); let pty = Pty::spawn(name, config)?; @@ -219,6 +220,7 @@ impl Pane { Some(supervisor_name), None, model, + effort, teams, ); let pty = Pty::spawn(name, config)?; @@ -238,6 +240,7 @@ impl Pane { worker_cli: SupervisorCli, worker_names: &[String], model: Option<&str>, + effort: Option<&str>, teams: Option<&TeamsSpawnConfig>, ) -> Result { let worker_cli_str = worker_cli.as_str(); @@ -257,6 +260,7 @@ impl Pane { None, Some(worker_cli_str), model, + effort, teams, ); Self::push_supervisor_env(&mut config.env, cli, &worker_names_csv); @@ -272,6 +276,7 @@ impl Pane { None, Some(worker_cli_str), model, + effort, teams, ); Self::push_supervisor_env(&mut config.env, cli, &worker_names_csv); diff --git a/crates/cas-pty/src/pty.rs b/crates/cas-pty/src/pty.rs index bb9390bfc..e0020127f 100644 --- a/crates/cas-pty/src/pty.rs +++ b/crates/cas-pty/src/pty.rs @@ -87,6 +87,7 @@ impl PtyConfig { supervisor_name: Option<&str>, factory_worker_cli: Option<&str>, model: Option<&str>, + effort: Option<&str>, teams: Option<&TeamsSpawnConfig>, ) -> Self { // Generate a stable session ID for this agent so the MCP server can @@ -150,15 +151,12 @@ impl PtyConfig { } // Supervisors need deeper reasoning for planning/coordination; // workers execute well-defined tasks where medium effort suffices. + // Config-provided effort takes precedence; role-based defaults preserve + // backward compatibility when no config value is set. + let resolved_effort = + effort.unwrap_or(if role == "supervisor" { "high" } else { "medium" }); args.push("--effort".to_string()); - args.push( - if role == "supervisor" { - "high" - } else { - "medium" - } - .to_string(), - ); + args.push(resolved_effort.to_string()); // Add native Agent Teams CLI flags. // All agents (including the supervisor) get --teammate-mode tmux @@ -209,6 +207,7 @@ impl PtyConfig { supervisor_name: Option<&str>, factory_worker_cli: Option<&str>, model: Option<&str>, + _effort: Option<&str>, _teams: Option<&TeamsSpawnConfig>, ) -> Self { // Native Agent Teams is Claude Code-only; Codex CLI does not support it. @@ -615,8 +614,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert_eq!(config.command, "claude"); assert!( @@ -650,8 +650,9 @@ mod tests { Some(&cas_root), None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -670,8 +671,9 @@ mod tests { None, Some("test-supervisor"), None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -690,8 +692,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -711,7 +714,8 @@ mod tests { None, None, Some("claude-opus-4-6"), - None, + None, // effort + None, // teams ); assert!(config.args.contains(&"--model".to_string())); assert!(config.args.contains(&"claude-opus-4-6".to_string())); @@ -726,8 +730,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!(!config.args.contains(&"--model".to_string())); } @@ -742,7 +747,8 @@ mod tests { None, None, Some("gpt-5.3-codex"), - None, + None, // effort + None, // teams ); assert!(config.args.contains(&"--model".to_string())); assert!(config.args.contains(&"gpt-5.3-codex".to_string())); @@ -757,8 +763,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); let all_args = config.args.join(" "); assert!( @@ -776,8 +783,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); let all_args = config.args.join(" "); assert!( @@ -803,7 +811,8 @@ mod tests { None, None, None, - None, + None, // model + None, // effort Some(&teams), ); assert!(config.args.contains(&"--team-name".to_string())); @@ -816,8 +825,9 @@ mod tests { assert!(config.args.contains(&"tmux".to_string())); assert!(config.args.contains(&"--parent-session-id".to_string())); assert!(config.args.contains(&"lead-session-123".to_string())); - // Workers should NOT have --session-id - assert!(!config.args.contains(&"--session-id".to_string())); + // All agents (including workers) get a generated --session-id so the MCP + // server can self-register without hooks. + assert!(config.args.contains(&"--session-id".to_string())); assert!( config .env @@ -911,7 +921,8 @@ mod tests { None, None, None, - None, + None, // model + None, // effort Some(&teams), ); // Lead also gets --teammate-mode so it polls its inbox From 308f799683b86c4fdc4ee7a3d0f7b498aa12fc0a Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Sat, 2 May 2026 16:01:11 -0400 Subject: [PATCH 22/22] fix(factory): wire worker_model through FactoryCore::new (autofix) FactoryCore::new was calling set_worker_cli and set_worker_effort but missing set_worker_model, leaving worker model config silently ignored when using the dynamic-spawn path. Detected and applied by cas-code-review autofix loop (round 1). Refs: cas-63d9 Co-Authored-By: Claude Sonnet 4.6 --- crates/cas-factory/src/core.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cas-factory/src/core.rs b/crates/cas-factory/src/core.rs index 021f5072c..08273bb20 100644 --- a/crates/cas-factory/src/core.rs +++ b/crates/cas-factory/src/core.rs @@ -134,6 +134,7 @@ impl FactoryCore { // Create mux with no panes initially let mut mux = Mux::new(rows, cols); mux.set_worker_cli(config.worker_cli); + mux.set_worker_model(config.worker_model.clone()); mux.set_worker_effort(config.worker_effort.clone()); // Initialize recording if enabled