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/.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`. diff --git a/cas-cli/src/cli/factory/daemon.rs b/cas-cli/src/cli/factory/daemon.rs index 2eee7ed75..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 { @@ -69,6 +71,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 +134,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 +156,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 +186,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 +209,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 e6b2edee5..a3f64f50a 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 @@ -600,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, @@ -613,6 +633,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; @@ -826,6 +847,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 +983,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 +1035,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/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] 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..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 @@ -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,15 +245,20 @@ 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 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, - &wrapped_message, + &message, factory_session.as_deref(), Some(summary.as_str()), + priority, ) .map_err(|error| { Self::error( @@ -286,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/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 { 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/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..a064b218d 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", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", + "abel", "tony", "walter", +]; + +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..9ec281b40 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)); @@ -113,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/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); + } + } } } 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/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..0b59212c3 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,35 @@ 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 +const MINION_LOGO_SMALL: &str = r#" + ▄██████▄ + ██ (◉◉) ██ + ██ ╰──╯ ██ + █▌▐████▌▐█ + █▌ │ │ ▐█ + ▀▀ ▀▀ +"#; + /// Braille spinner frames for smooth animation const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -131,11 +245,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 +279,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 +343,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 +362,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 +374,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; self.stdout.flush()?; @@ -224,7 +384,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; } @@ -237,19 +397,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 +453,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 +512,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 +530,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 +541,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 +562,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 +575,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 +589,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 +614,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 +627,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 +669,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 +677,7 @@ impl BootScreen { // Draw empty portion execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_EMPTY), + SetForegroundColor(empty_color), Print("░".repeat(empty_chars)) )?; @@ -528,12 +691,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 +705,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 +732,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 +744,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 +755,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/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/cas-cli/src/ui/factory/daemon/runtime/client_input.rs b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs index 276fa4bcf..22c77891d 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,55 @@ 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) +} + +#[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/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/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, 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/cas-cli/src/ui/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 5d5fe5ec1..1d625e3ad 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 { @@ -165,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 4acee496e..aaf7cda79 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 { @@ -121,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 a25a7dc5d..b982a1be1 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -111,3 +111,39 @@ 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) +} + +#[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); + } +} 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..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) @@ -189,6 +193,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 { @@ -202,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(), @@ -211,6 +219,7 @@ impl Default for FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } } diff --git a/crates/cas-factory/src/core.rs b/crates/cas-factory/src/core.rs index 70b396452..08273bb20 100644 --- a/crates/cas-factory/src/core.rs +++ b/crates/cas-factory/src/core.rs @@ -134,6 +134,8 @@ 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 let recording = if config.record { @@ -203,6 +205,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 +464,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(), @@ -470,6 +475,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 4b1f30047..e020ca042 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). @@ -105,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(); @@ -128,14 +152,17 @@ 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(); + + // 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; - // Send multiple notifications + // 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 diff --git a/crates/cas-factory/tests/factory_integration.rs b/crates/cas-factory/tests/factory_integration.rs index 792905829..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(), @@ -195,6 +197,7 @@ fn test_config() -> FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index 3a5e5a78a..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 @@ -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 @@ -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) ========== @@ -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) @@ -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) @@ -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..e59dc913f --- /dev/null +++ b/crates/cas-mcp/src/types/deser.rs @@ -0,0 +1,80 @@ +//! 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; + +/// Generate an `Option<$target>` deserializer that accepts numbers, strings, and null. +/// +/// 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 { + 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 { + <$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)), + ) + }) + } + } + + 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 bb23447c5..981e5e9b7 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)] + #[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, } @@ -448,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. @@ -458,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, @@ -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)] + #[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 73a3888ec..36cfda020 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -139,3 +139,424 @@ 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); +} + +// ===== 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)); +} 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 b85ba2284..e0020127f 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__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__cs__coordination action=session_start name="; + /// Configuration for spawning a PTY #[derive(Debug, Clone)] pub struct PtyConfig { @@ -78,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 @@ -141,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 @@ -200,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. @@ -252,23 +260,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__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__cs__coordination action=message target=supervisor confirming ready state\n\ + 6) Do NOT message target=cas. Use target=supervisor." ); args.push(startup_prompt); } @@ -605,8 +614,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert_eq!(config.command, "claude"); assert!( @@ -640,8 +650,9 @@ mod tests { Some(&cas_root), None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -660,8 +671,9 @@ mod tests { None, Some("test-supervisor"), None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -680,8 +692,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!( config @@ -701,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())); @@ -716,8 +730,9 @@ mod tests { None, None, None, - None, - None, + None, // model + None, // effort + None, // teams ); assert!(!config.args.contains(&"--model".to_string())); } @@ -732,12 +747,53 @@ 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())); } + #[tokio::test] + async fn test_pty_config_codex_worker_uses_cs_prefix() { + let config = PtyConfig::codex( + "test-worker", + "worker", + PathBuf::from("/tmp"), + None, + None, + None, + None, // model + None, // effort + None, // teams + ); + let all_args = config.args.join(" "); + assert!( + all_args.contains("mcp__cs__"), + "Codex worker instructions should 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, // model + None, // effort + None, // teams + ); + 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 { @@ -755,7 +811,8 @@ mod tests { None, None, None, - None, + None, // model + None, // effort Some(&teams), ); assert!(config.args.contains(&"--team-name".to_string())); @@ -768,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 @@ -778,6 +836,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 { @@ -795,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 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 f3c0a57a5..396e07031 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,10 @@ pub struct QueuedPrompt { pub processed_at: Option>, /// Short summary for UI display 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 @@ -56,6 +61,37 @@ 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; +"#; + +/// 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) @@ -73,7 +109,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 +117,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) @@ -109,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; @@ -147,6 +205,9 @@ 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); + 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)?, @@ -156,6 +217,8 @@ impl SqlitePromptQueueStore { created_at: Self::parse_datetime(&row.get::<_, String>(4)?).unwrap_or_else(Utc::now), processed_at, summary, + priority: NotificationPriority::from(priority), + acked_at, }) } } @@ -181,24 +244,27 @@ 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)?; + } + + // 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(()) } 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 +274,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 +306,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, acked_at 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 +345,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, acked_at FROM prompt_queue WHERE processed_at IS NULL - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?", )?; @@ -325,10 +383,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, acked_at FROM prompt_queue WHERE processed_at IS NULL - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?", )?; @@ -372,11 +430,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, acked_at FROM prompt_queue WHERE processed_at IS NULL AND ({where_clause}) - ORDER BY created_at ASC + ORDER BY priority ASC, id ASC LIMIT ?" ); @@ -405,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(); @@ -593,4 +721,173 @@ 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"); + } + + #[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()); + } }