Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 22 additions & 28 deletions docs/familiars.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ Workspace Agents ← .coven-code/agents/*.md
• my-custom-agent default · user

✨ Coven Familiars ← ~/.coven/familiars.toml
Nova ✨ Orchestrator — Your personal AI ...
Sage 🧙 Research — Deep reasoning and ...
Cody 🤖 Code — Focused implementation ...
Dev 🤖 Code — Focused implementation ...
Research 🧙 Research — Deep reasoning and ...
Writer ✍️ Writing — Docs and communication ...
```

Select a familiar to see its full detail view, including persona preview and the suggested `--agent` invocation.
Expand Down Expand Up @@ -170,8 +170,8 @@ The `access` field controls **which tools** a familiar may invoke once you selec

| Tier | What the familiar can do | Typical role |
|---|---|---|
| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` |
| `read-only` | Read & search the workspace plus `AskUserQuestion`, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` |
| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars that edit and run code |
| `read-only` | Read & search the workspace plus `AskUserQuestion`, no writes or shell. **Default.** | Research / strategy familiars |
| `search-only` | Narrow read+search whitelist: `Grep`, `Glob`, `Read`, `WebSearch`, `WebFetch`. No writes or shell. | Pure-research personas with minimal codebase footprint |

> **Unknown values fail closed.** Case and surrounding whitespace are normalized silently — `"READ-ONLY"`, `"Read-Only"`, and `" full "` all map to their canonical tier. Anything else (a typo like `"readonly"`, an invented tier like `"super-admin"`, an empty string) is treated as `"read-only"` and a warning is printed to stderr. Typos cannot silently grant write/exec power.
Expand All @@ -197,15 +197,15 @@ The `access` field controls **which tools** a familiar may invoke once you selec
```toml
# Build-tier — can edit and run.
[[familiar]]
id = "cody"
display_name = "Cody"
id = "dev"
display_name = "Dev"
role = "Code"
access = "full"

# Research-tier — read-only by default (no `access` line needed).
[[familiar]]
id = "sage"
display_name = "Sage"
id = "research"
display_name = "Research"
role = "Research"
```

Expand Down Expand Up @@ -255,29 +255,23 @@ Every saved familiar from `~/.coven/familiars.toml` renders as a **static themed
2. The **F2 switcher popup**: one row per saved familiar, each painted in that familiar's accent palette with a coloured tier dot.
3. The **`/agents` detail view**: the card appears above the persona preview when you select a familiar-sourced agent.

The glyph itself does **not** animate. The only motion is a quarter-block eye spinner that kicks in when the assistant has gone quiet for ~3 seconds, so you still get a "thinking" signal without a walking mascot pulling attention from the work area.
The glyph is a procedural sigil framing the familiar's emoji. Its accent colour pulses gently while idle and pulses faster when the assistant has gone quiet for ~3 seconds, so you get a "thinking" signal without a walking mascot pulling attention from the work area.

Cards adapt to available room:

- **Compact** (narrow terminals): glyph only, no border.
- **Standard** (default): glyph + name + tier dot inside a rounded border.
- **Large** (wide terminals): adds the role line and an accent rule under the glyph.

### Built-in glyphs

| ID | Concept | Accent |
|---|---|---|
| `kitty` | Cat head — ears, whiskers, square eyes (default) | violet |
| `nova` | Crowned sorceress with star sparkles | gold |
| `cody` | Robot face — antenna, bracket eyes, code body | cyan |
| `charm` | Heart with sparkle dots | pink |
| `sage` | Wizard hat + star + open book | emerald |
| `astra` | Crescent moon + compass star + orbit | indigo |
| `echo` | Round ghost + bracket eyes + echo dots | teal |

### User-defined familiars
### Procedural glyphs

Any familiar declared in `~/.coven/familiars.toml` automatically gets a procedurally-generated card. The accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked deterministically from the familiar's `id`, so the same familiar looks the same across sessions and machines without storing extra config. The familiar's `emoji` is rendered inside the frame.
There is **no built-in roster** — nothing ships with a named familiar, so a
fresh install never inherits one. Every familiar declared in
`~/.coven/familiars.toml` automatically gets a procedurally-generated card. The
accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked
deterministically from the familiar's `id`, so the same familiar looks the same
across sessions and machines without storing extra config. The familiar's
`emoji` is rendered inside the frame.

If you want a hand-crafted image instead of the procedural sigil, drop a PNG/JPG/WebP at `~/.coven/assets/familiars/<id>.<ext>`. When the terminal supports Kitty or Sixel inline graphics, that image takes precedence over the card.

Expand All @@ -287,14 +281,14 @@ Set `familiar` in your settings:

```json
{
"familiar": "nova"
"familiar": "dev"
}
```

Or run:

```
coven-code config set familiar nova
coven-code config set familiar dev
```

When `~/.coven/familiars.toml` contains saved familiars, you can also press
Expand All @@ -317,8 +311,8 @@ agent markdown files, and saved agent/familiar settings. After reset the
welcome panel renders `Familiar: none`, the footer shows no familiar label,
and the F2 familiar switcher does not open until a saved familiar roster
exists again. The `/familiar` command only selects familiars from
`~/.coven/familiars.toml`; stale settings or built-in names are ignored when
the roster file is absent.
`~/.coven/familiars.toml`; stale settings are ignored when the roster file is
absent.

---

Expand Down
14 changes: 7 additions & 7 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5323,15 +5323,15 @@ mod tests {
#[test]
fn startup_agent_defaults_to_configured_familiar() {
assert_eq!(
startup_agent_name(None, Some("Echo")),
Some("echo".to_string())
startup_agent_name(None, Some("Wisp")),
Some("wisp".to_string())
);
}

#[test]
fn startup_agent_prefers_explicit_agent_over_familiar() {
assert_eq!(
startup_agent_name(Some("Explore"), Some("Echo")),
startup_agent_name(Some("Explore"), Some("Wisp")),
Some("explore".to_string())
);
}
Expand Down Expand Up @@ -5424,8 +5424,8 @@ mod tests {
"issue_body": "Tokens expire early."
},
"familiar": {
"id": "cody",
"display_name": "Cody",
"id": "ember",
"display_name": "Ember",
"model": "anthropic/claude-sonnet-4-6",
"skills": ["systematic-debugging"]
},
Expand Down Expand Up @@ -5459,7 +5459,7 @@ mod tests {
#[test]
fn github_output_envelope_includes_git_summary() {
let git = GitResultSummary {
branch: Some("cody/fix-auth-refresh".to_string()),
branch: Some("ember/fix-auth-refresh".to_string()),
commits: vec![GitCommitSummary {
sha: "abc123".to_string(),
message: "fix auth refresh".to_string(),
Expand All @@ -5470,7 +5470,7 @@ mod tests {
let envelope = github_output_envelope(true, &git);

assert_eq!(envelope["status"], "success");
assert_eq!(envelope["branch"], "cody/fix-auth-refresh");
assert_eq!(envelope["branch"], "ember/fix-auth-refresh");
assert_eq!(envelope["commits"][0]["sha"], "abc123");
assert_eq!(envelope["files_changed"][0], "src/auth.rs");
assert_eq!(envelope["exit_reason"], serde_json::Value::Null);
Expand Down
28 changes: 14 additions & 14 deletions src-rust/crates/tui/src/agents_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,8 @@ mod tests {

fn status(status: &str, active_sessions: u32) -> coven_shared::FamiliarStatus {
coven_shared::FamiliarStatus {
id: "sage".to_string(),
display_name: "Sage".to_string(),
id: "wisp".to_string(),
display_name: "Wisp".to_string(),
emoji: String::new(),
status: status.to_string(),
active_sessions,
Expand Down Expand Up @@ -763,12 +763,12 @@ mod tests {
#[test]
fn familiar_id_from_source_parses_coven_familiar_prefix() {
assert_eq!(
familiar_id_from_source("coven:familiar:cody"),
Some("cody".to_string())
familiar_id_from_source("coven:familiar:ember"),
Some("ember".to_string())
);
assert_eq!(
familiar_id_from_source("coven:familiar:Nova"),
Some("nova".to_string())
familiar_id_from_source("coven:familiar:Onyx"),
Some("onyx".to_string())
);
assert_eq!(familiar_id_from_source("user"), None);
assert_eq!(familiar_id_from_source("plugin:foo"), None);
Expand Down Expand Up @@ -807,8 +807,8 @@ mod tests {
#[test]
fn familiar_as_agent_def_matches_core_conversion() {
let fam = coven_shared::CovenFamiliar {
id: "Cody".to_string(),
display_name: Some("Cody".to_string()),
id: "Ember".to_string(),
display_name: Some("Ember".to_string()),
emoji: Some("⚡".to_string()),
role: Some("Code".to_string()),
description: Some("Builds and ships.".to_string()),
Expand All @@ -827,7 +827,7 @@ mod tests {
#[test]
fn familiar_as_agent_def_defaults_access_to_read_only() {
let fam = coven_shared::CovenFamiliar {
id: "sage".to_string(),
id: "wisp".to_string(),
display_name: None,
emoji: None,
role: None,
Expand All @@ -842,12 +842,12 @@ mod tests {
#[test]
fn confirm_selection_returns_familiar_id_from_list_route() {
let mut state = AgentsMenuState::new();
state.definitions = vec![user_def("review"), familiar_def("cody", "Cody")];
state.definitions = vec![user_def("review"), familiar_def("ember", "Ember")];
state.route = AgentsRoute::List;
// selected_row 0 = "Create new"; row 1 = reset; row 2 = first def; row 3 = familiar.
state.selected_row = 3;
let result = state.confirm_selection();
assert_eq!(result, Some(("cody".to_string(), "Cody".to_string())));
assert_eq!(result, Some(("ember".to_string(), "Ember".to_string())));
// List route is unchanged — caller is responsible for closing the menu.
assert!(matches!(state.route, AgentsRoute::List));
}
Expand Down Expand Up @@ -876,10 +876,10 @@ mod tests {
#[test]
fn confirm_selection_returns_familiar_id_from_detail_route() {
let mut state = AgentsMenuState::new();
state.definitions = vec![familiar_def("nova", "Nova")];
state.definitions = vec![familiar_def("onyx", "Onyx")];
state.route = AgentsRoute::Detail(0);
let result = state.confirm_selection();
assert_eq!(result, Some(("nova".to_string(), "Nova".to_string())));
assert_eq!(result, Some(("onyx".to_string(), "Onyx".to_string())));
}

#[test]
Expand Down Expand Up @@ -914,7 +914,7 @@ mod tests {
#[test]
fn delete_selected_definition_rejects_coven_familiar() {
let mut state = AgentsMenuState::new();
state.definitions = vec![familiar_def("nova", "Nova")];
state.definitions = vec![familiar_def("onyx", "Onyx")];
state.route = AgentsRoute::List;
state.selected_row = 2;

Expand Down
46 changes: 24 additions & 22 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4832,16 +4832,18 @@ impl App {
Some("No saved familiars. Add ~/.coven/familiars.toml first.".to_string());
} else {
self.familiar_switcher_open = true;
let current = self.config.familiar.as_deref().unwrap_or("kitty");
if let Some(idx) = self
.familiar_switcher_list
.iter()
.position(|id| id == current)
{
self.familiar_switcher_idx = idx;
} else {
self.familiar_switcher_idx = 0;
}
// Highlight the active familiar if one is set; otherwise
// start at the top. No built-in default is assumed.
self.familiar_switcher_idx = self
.config
.familiar
.as_deref()
.and_then(|current| {
self.familiar_switcher_list
.iter()
.position(|id| id == current)
})
.unwrap_or(0);
}
}
KeyCode::Char('?')
Expand Down Expand Up @@ -7426,8 +7428,8 @@ mod tests {
#[test]
fn cycle_agent_mode_clears_visible_familiar() {
let mut app = make_app();
app.config.familiar = Some("echo".to_string());
app.agent_mode = Some("echo".to_string());
app.config.familiar = Some("wisp".to_string());
app.agent_mode = Some("wisp".to_string());

app.cycle_agent_mode();

Expand All @@ -7447,7 +7449,7 @@ mod tests {
std::fs::create_dir_all(&project).expect("project dir");
std::fs::write(
coven_home.join("familiars.toml"),
"[[familiar]]\nid = \"nova\"\n",
"[[familiar]]\nid = \"ember\"\n",
)
.expect("familiar roster");
let guard = EnvGuard::set(&home, &coven_home);
Expand All @@ -7457,16 +7459,16 @@ mod tests {
app.config
.agents
.insert("custom".to_string(), test_agent_definition());
app.config.familiar = Some("nova".to_string());
app.config.familiar = Some("ember".to_string());
app.config.managed_agents = Some(test_managed_agents());
app.config.permission_mode = claurst_core::config::PermissionMode::Plan;
app.agent_mode = Some("nova".to_string());
app.agent_mode = Some("ember".to_string());
app.agent_mode_changed = false;
app.accent_color = ACCENT_PLAN;
app.plan_mode = true;
app.managed_agents_active = true;
app.familiar_switcher_open = true;
app.familiar_switcher_list = vec!["nova".to_string(), "kitty".to_string()];
app.familiar_switcher_list = vec!["ember".to_string(), "onyx".to_string()];
app.familiar_switcher_idx = 1;

app.reset_agents_and_familiars();
Expand Down Expand Up @@ -7510,7 +7512,7 @@ mod tests {
let coven_home = temp.path().join("coven");
std::fs::create_dir_all(&home).expect("home dir");
std::fs::create_dir_all(&coven_home).expect("coven home dir");
let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("sage"));
let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("ember"));

let app = make_app();

Expand All @@ -7529,18 +7531,18 @@ mod tests {
coven_home.join("familiars.toml"),
r#"
[[familiar]]
id = "sage"
display_name = "Sage"
id = "wisp"
display_name = "Wisp"
role = "Research"
"#,
)
.expect("familiars file");
let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("sage"));
let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("wisp"));

let app = make_app();

assert_eq!(app.config.familiar.as_deref(), Some("sage"));
assert_eq!(app.familiar_switcher_list, vec!["sage".to_string()]);
assert_eq!(app.config.familiar.as_deref(), Some("wisp"));
assert_eq!(app.familiar_switcher_list, vec!["wisp".to_string()]);
}

#[test]
Expand Down
Loading