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
24 changes: 24 additions & 0 deletions docs/familiars.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ access = "read-only"
| `description` | | Full description used to build the persona system prompt. |
| `pronouns` | | Appended to the persona prompt if present. |
| `access` | | Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`. Defaults to `"read-only"` when omitted. See [Tool access tiers](#tool-access-tiers) below. |
| `model` | | Optional model override for this familiar, e.g. `"claude-opus-4-8"`. When omitted the familiar inherits the session's default model. Lets you pin a persona to a model without shadowing it with a workspace agent. |

---

## Managing the roster

`~/.coven/familiars.toml` is **owned by the Coven daemon** when it is running. In that mode coven-code treats the roster as read-only and directs all edits to the daemon.

**Standalone mode** (no daemon socket at `~/.coven/coven.sock`) is different: coven-code owns the file and can write it directly.

- **First-run bootstrap.** Press **F2** with no familiars configured and coven-code writes a starter `~/.coven/familiars.toml` (a `read-only` guide and a `full`-access builder) and opens the switcher so you have something to pick and a template to edit.
- **Create / rename / remove** from the prompt:

```text
/familiar new <id> [display name] # create a read-only familiar
/familiar rename <old-id> <new-id> # rename, preserving all fields
/familiar remove <id> # delete (clears it if it was active)
```

You can also remove a familiar from the visual `/familiar` menu. All of these refuse with a clear message when the daemon owns the file.
- **Switching:** `/familiar <id>`, the **F2** quick switcher (type to filter, `— none —` clears the active familiar), or the `/familiar` menu.
- **Clearing vs wiping:** `/familiar clear` (alias `reset`) steps back to no active familiar. `/familiar wipe-roster` (alias `reset-roster`) is **destructive** — it deletes the roster and workspace agents — and requires `/familiar wipe-roster confirm`.

If the roster file is malformed, coven-code surfaces the parse error at startup instead of silently dropping every familiar; unknown access tiers and duplicate/reserved ids are reported as warnings.

---

Expand Down
55 changes: 53 additions & 2 deletions src-rust/crates/api/src/error_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ pub fn is_context_overflow(message: &str) -> bool {
/// is classified as [`ProviderError::ContextOverflow`] rather than
/// [`ProviderError::InvalidRequest`].
pub fn parse_error_response(status: u16, body: &str, provider: &ProviderId) -> ProviderError {
parse_error_response_with_retry_after(status, body, provider, None)
}

/// Parse a `Retry-After` header value into whole seconds.
///
/// Supports the delay-seconds form (`Retry-After: 30`). The HTTP-date form is
/// not parsed and yields `None` (callers fall back to their own back-off).
pub fn parse_retry_after_secs(value: &str) -> Option<u64> {
value.trim().parse::<u64>().ok()
}

/// Like [`parse_error_response`], but threads a caller-extracted `Retry-After`
/// value (in seconds) into the [`ProviderError::RateLimited`] variant so the
/// server-provided back-off delay is preserved and surfaced to the user.
pub fn parse_error_response_with_retry_after(
status: u16,
body: &str,
provider: &ProviderId,
retry_after: Option<u64>,
) -> ProviderError {
let json: Option<serde_json::Value> = serde_json::from_str(body).ok();

let message = if let Some(ref j) = json {
Expand Down Expand Up @@ -142,7 +162,7 @@ pub fn parse_error_response(status: u16, body: &str, provider: &ProviderId) -> P
},
429 => ProviderError::RateLimited {
provider: provider.clone(),
retry_after: None,
retry_after,
},
413 => ProviderError::ContextOverflow {
provider: provider.clone(),
Expand Down Expand Up @@ -312,7 +332,38 @@ mod tests {
fn test_parse_error_response_rate_limit() {
let pid = ProviderId::new("openai");
let err = parse_error_response(429, "rate limited", &pid);
assert!(matches!(err, ProviderError::RateLimited { .. }));
assert!(matches!(
err,
ProviderError::RateLimited {
retry_after: None,
..
}
));
}

#[test]
fn test_parse_error_response_threads_retry_after() {
let pid = ProviderId::new("openai");
let err = parse_error_response_with_retry_after(429, "rate limited", &pid, Some(42));
assert!(matches!(
err,
ProviderError::RateLimited {
retry_after: Some(42),
..
}
));
}

#[test]
fn test_parse_retry_after_secs() {
assert_eq!(parse_retry_after_secs("30"), Some(30));
assert_eq!(parse_retry_after_secs(" 15 "), Some(15));
// HTTP-date form is unsupported → None (caller falls back to back-off).
assert_eq!(
parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
None
);
assert_eq!(parse_retry_after_secs(""), None);
}

#[test]
Expand Down
27 changes: 24 additions & 3 deletions src-rust/crates/api/src/providers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use futures::{Stream, StreamExt};
use serde_json::{json, Value};
use tracing::{debug, warn};

use crate::error_handling::parse_error_response;
use crate::error_handling::{parse_error_response_with_retry_after, parse_retry_after_secs};
use crate::provider::{LlmProvider, ModelInfo};
use crate::provider_error::ProviderError;
use crate::provider_types::{
Expand All @@ -39,6 +39,15 @@ use crate::provider_types::{

use crate::providers::responses_input::to_responses_input;

/// Extract a `Retry-After` delay (in seconds) from response headers, if the
/// server sent one in the delay-seconds form.
fn retry_after_from_headers(headers: &reqwest::header::HeaderMap) -> Option<u64> {
headers
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after_secs)
}

// ---------------------------------------------------------------------------
// CodexProvider
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -333,6 +342,7 @@ impl CodexProvider {
})?;

let status = resp.status().as_u16();
let retry_after = retry_after_from_headers(resp.headers());
let text = resp.text().await.map_err(|e| ProviderError::Other {
provider: self.id.clone(),
message: format!("Failed to read response body: {}", e),
Expand All @@ -341,7 +351,12 @@ impl CodexProvider {
})?;

if !(200..300).contains(&(status as usize)) {
return Err(parse_error_response(status, &text, &self.id));
return Err(parse_error_response_with_retry_after(
status,
&text,
&self.id,
retry_after,
));
}

let json_val: Value = serde_json::from_str(&text).map_err(|e| ProviderError::Other {
Expand Down Expand Up @@ -384,13 +399,19 @@ impl CodexProvider {

let status = resp.status().as_u16();
if !(200..300).contains(&(status as usize)) {
let retry_after = retry_after_from_headers(resp.headers());
let text = resp.text().await.map_err(|e| ProviderError::Other {
provider: self.id.clone(),
message: format!("Failed to read response body: {}", e),
status: Some(status),
body: None,
})?;
return Err(parse_error_response(status, &text, &self.id));
return Err(parse_error_response_with_retry_after(
status,
&text,
&self.id,
retry_after,
));
}

Ok(resp)
Expand Down
135 changes: 125 additions & 10 deletions src-rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8580,6 +8580,95 @@ fn infer_familiar_from_env() -> Option<String> {
})
}

/// Refusal message when the Coven daemon owns the roster file.
fn daemon_owned_refusal() -> CommandResult {
CommandResult::Error(
"Familiars are managed by the running Coven daemon — add, rename, or remove them \
through the daemon. coven-code only writes ~/.coven/familiars.toml in standalone mode."
.to_string(),
)
}

/// `/familiar new <id> [display name...]` — create a standalone familiar.
fn familiar_new(rest: &str) -> CommandResult {
let mut parts = rest.splitn(2, char::is_whitespace);
let id = parts.next().unwrap_or("").trim();
if id.is_empty() {
return CommandResult::Error("Usage: /familiar new <id> [display name]".to_string());
}
if !claurst_core::coven_shared::can_write_familiars() {
return daemon_owned_refusal();
}
let display = parts.next().map(str::trim).filter(|s| !s.is_empty());
let fam = claurst_core::coven_shared::CovenFamiliar {
id: id.to_string(),
display_name: display.map(str::to_string),
emoji: None,
role: None,
description: None,
pronouns: None,
access: Some(claurst_core::coven_shared::DEFAULT_FAMILIAR_ACCESS.to_string()),
model: None,
};
match claurst_core::coven_shared::create_familiar(fam) {
Ok(()) => CommandResult::Message(format!(
"Created familiar '{id}' (access: {}). Edit ~/.coven/familiars.toml to customize, \
then switch with /familiar {id}.",
claurst_core::coven_shared::DEFAULT_FAMILIAR_ACCESS,
)),
Err(e) => CommandResult::Error(format!("Could not create familiar: {e}")),
}
}

/// `/familiar remove <id>` — delete a standalone familiar. Clears the active
/// familiar too if it was the one removed.
fn familiar_remove(rest: &str, ctx: &CommandContext) -> CommandResult {
let id = rest.trim();
if id.is_empty() {
return CommandResult::Error("Usage: /familiar remove <id>".to_string());
}
if !claurst_core::coven_shared::can_write_familiars() {
return daemon_owned_refusal();
}
match claurst_core::coven_shared::remove_familiar(id) {
Ok(removed) => {
let was_active = ctx
.config
.familiar
.as_deref()
.map(|a| a.eq_ignore_ascii_case(&removed.id))
.unwrap_or(false);
if was_active {
let _ = save_settings_mutation(|s| s.config.familiar = None);
let mut new_config = ctx.config.clone();
new_config.familiar = None;
return CommandResult::ConfigChangeMessage(
new_config,
format!("Removed familiar '{}' (was active — cleared).", removed.id),
);
}
CommandResult::Message(format!("Removed familiar '{}'.", removed.id))
}
Err(e) => CommandResult::Error(format!("Could not remove familiar: {e}")),
}
}

/// `/familiar rename <old> <new>` — rename a standalone familiar's id.
fn familiar_rename(rest: &str) -> CommandResult {
let mut parts = rest.split_whitespace();
let (old_id, new_id) = (parts.next(), parts.next());
let (Some(old_id), Some(new_id)) = (old_id, new_id) else {
return CommandResult::Error("Usage: /familiar rename <old-id> <new-id>".to_string());
};
if !claurst_core::coven_shared::can_write_familiars() {
return daemon_owned_refusal();
}
match claurst_core::coven_shared::rename_familiar(old_id, new_id) {
Ok(()) => CommandResult::Message(format!("Renamed familiar '{old_id}' → '{new_id}'.")),
Err(e) => CommandResult::Error(format!("Could not rename familiar: {e}")),
}
}

#[async_trait]
impl SlashCommand for FamiliarCommand {
fn name(&self) -> &str {
Expand All @@ -8592,7 +8681,7 @@ impl SlashCommand for FamiliarCommand {
vec!["familiars", "agent"]
}
fn help(&self) -> &str {
"Usage: /familiar [name | info <name> | list|create|edit|delete [name] | managed ... | reset | reset-roster | auto]\n\n\
"Usage: /familiar [name | info <name> | list|create|edit|delete [name] | managed ... | clear | wipe-roster | auto]\n\n\
One surface for familiars and agents — both resolve through the same\n\
runtime agent map and access-tier security under the hood.\n\n\
/familiar Current familiar, roster, and agents (with access tiers)\n\
Expand All @@ -8601,9 +8690,13 @@ impl SlashCommand for FamiliarCommand {
/familiar <agent> Details for a built-in or workspace agent\n\
/familiar info <name> Details for any familiar or agent\n\
/familiar list|create|edit|delete [name] Manage workspace agents\n\
/familiar new <id> [name] Create a familiar (standalone; writes ~/.coven/familiars.toml)\n\
/familiar rename <old> <new> Rename a familiar (standalone)\n\
/familiar remove <id> Delete a familiar (standalone)\n\
/familiar managed ... Manager-executor architecture (presets, budget)\n\
/familiar reset Clear the active familiar\n\
/familiar reset-roster Reset saved familiars and workspace agents\n\
/familiar clear Step back to no active familiar (alias: reset)\n\
/familiar wipe-roster DESTRUCTIVE — delete saved familiars + workspace\n\
agents. Requires `wipe-roster confirm` (alias: reset-roster)\n\
/familiar auto Infer your familiar from the system username\n\n\
Roster lives in ~/.coven/familiars.toml (Coven daemon). In the TUI,\n\
bare /familiar opens the visual menu and F2 is the quick switcher."
Expand All @@ -8623,10 +8716,26 @@ impl SlashCommand for FamiliarCommand {
}
// Manager-executor architecture (absorbed from /managed-agents).
"managed" => return ManagedAgentsCommand.execute(rest, ctx).await,
// The old `/agents reset` roster wipe. `/familiar reset` clears
// the active familiar instead, so the destructive variant gets
// an explicit, unambiguous name.
"reset-roster" => {
// The old `/agents reset` roster wipe. `/familiar reset`/`clear`
// clears the active familiar instead, so the destructive variant
// gets an explicit, unambiguous name AND a confirmation gate —
// `reset` and `reset-roster` are one keystroke apart and the latter
// is irreversible.
"reset-roster" | "wipe-roster" => {
let confirmed = matches!(
rest.trim().to_lowercase().as_str(),
"confirm" | "yes" | "--yes" | "-y"
);
if !confirmed {
return CommandResult::Message(
"\u{26a0} /familiar wipe-roster is destructive. It deletes \
~/.coven/familiars.toml, removes workspace agents \
(.coven-code/agents/*.md), and clears your active familiar.\n\n\
Re-run `/familiar wipe-roster confirm` to proceed.\n\
To simply step back to no active familiar, use `/familiar clear`."
.to_string(),
);
}
return execute_named_command_from_slash("agents", "reset", ctx);
}
"info" => {
Expand All @@ -8644,6 +8753,11 @@ impl SlashCommand for FamiliarCommand {
)),
};
}
// Standalone-only roster writes. When the Coven daemon is running it
// owns ~/.coven/familiars.toml, so these refuse and defer to it.
"new" => return familiar_new(rest),
"remove" => return familiar_remove(rest, ctx),
"rename" => return familiar_rename(rest),
_ => {}
}

Expand All @@ -8654,8 +8768,9 @@ impl SlashCommand for FamiliarCommand {
return CommandResult::Message(familiar_overview(ctx));
}

// Resolve switch target.
let target = if arg == "reset" {
// Resolve switch target. `clear` is the preferred, unambiguous name
// for stepping back to no active familiar; `reset` is kept as an alias.
let target = if arg == "reset" || arg == "clear" {
None
} else if arg == "auto" {
match infer_familiar_from_env() {
Expand Down Expand Up @@ -8715,7 +8830,7 @@ impl SlashCommand for FamiliarCommand {
name, desc, access
)
}
None => "Familiar reset to none.".to_string(),
None => "Familiar cleared — no active persona.".to_string(),
};

CommandResult::ConfigChangeMessage(new_config, msg)
Expand Down
Loading