Skip to content
Open
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,42 @@ baton is also an MCP server — your agent can pass the baton itself, mid-conver
| `convert_session` | Convert a session from one format to another |
| `import_to_target` | Convert + run the target agent's import command |
| `detect_format` | Sniff a file/dir and report which agent produced it |
| `failover_opt_in` | Allowlist a session for unattended failover (see below) |
| `failover_opt_out` | Remove a session from the failover allowlist |
| `failover_status` | List sessions opted in to unattended failover |

```sh
baton install # registers baton in every detected agent's MCP config
baton doctor # verify
baton uninstall # remove from all agents
```

## Auto-failover

Solve the 4 p.m. problem before you notice it happened. `baton watch` polls the
current project's sessions for quota-death ("usage limit reached") and passes
the baton for you: convert, import, and hand you a ready-to-run resume command.

```sh
cd your-project
baton watch # interactive: lists quota-dead sessions, you pick
baton watch --auto # unattended: fails over opted-in sessions, no prompt
baton watch --once --auto # single scan (wire it into an agent hook)
baton watch --to codex # override the failover target
```

Quota is account-level — when it dies, *every* active session errors at once, so
failing all of them over would be spam. Interactive mode lets you pick at fire
time; `--auto` only touches sessions you've allowlisted by telling your agent
mid-conversation:

> opt this session into failover

which calls the `failover_opt_in` MCP tool. Detection currently covers
**Claude Code** (transcript tail) and **OpenCode** (via `opencode db`); the
default failover pair is claude-code ⇄ opencode. Run `baton watch` from the
project directory you're working in — imports land under the process cwd.

## How it works

```
Expand Down
15 changes: 9 additions & 6 deletions src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ pub fn import_to_target(to: Agent, file: &Path) -> anyhow::Result<()> {
}
}

/// Claude encodes a project directory by replacing path separators (and dots) with dashes.
pub fn encode_claude_project_dir(dir: &Path) -> String {
dir.to_string_lossy()
.chars()
.map(|c| if c == '/' || c == '\\' || c == '.' || c == ':' { '-' } else { c })
.collect()
}

/// Place a converted .jsonl where Claude Code will find it:
/// `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`, resumable via `claude --resume <uuid>`.
fn import_claude(file: &Path) -> anyhow::Result<()> {
Expand Down Expand Up @@ -112,13 +120,8 @@ fn import_claude(file: &Path) -> anyhow::Result<()> {
}
};

// Claude encodes the project cwd by replacing path separators (and dots) with dashes.
let cwd = std::env::current_dir().context("getting cwd")?;
let encoded: String = cwd
.to_string_lossy()
.chars()
.map(|c| if c == '/' || c == '\\' || c == '.' || c == ':' { '-' } else { c })
.collect();
let encoded = encode_claude_project_dir(&cwd);
let dir = dirs::home_dir()
.context("no home dir")?
.join(".claude")
Expand Down
33 changes: 33 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod convert;
mod detect;
mod formats;
mod pick;
mod watch;

#[cfg(feature = "mcp")]
mod mcp;
Expand Down Expand Up @@ -68,6 +69,25 @@ enum Cmd {
},
/// Run as an MCP server (stdio transport). Called by agents automatically.
Serve,
/// Watch this project's sessions for quota-death and pass the baton automatically.
Watch {
/// Unattended: fail over opted-in sessions without prompting
/// (opt in via the `failover_opt_in` MCP tool).
#[arg(long)]
auto: bool,
/// Scan once and exit (useful from agent hooks) instead of polling.
#[arg(long)]
once: bool,
/// Poll interval in seconds.
#[arg(long, default_value_t = 15)]
interval: u64,
/// Failover target agent (default: claude-code ⇄ opencode).
#[arg(long, value_parser = parse_agent)]
to: Option<Agent>,
/// Project directory to watch (default: current directory).
#[arg(long)]
project: Option<PathBuf>,
},
}

fn main() -> anyhow::Result<()> {
Expand Down Expand Up @@ -108,6 +128,19 @@ fn main() -> anyhow::Result<()> {
#[cfg(not(feature = "mcp"))]
anyhow::bail!("baton was built without the `mcp` feature; rebuild with --features mcp");
}
Cmd::Watch {
auto,
once,
interval,
to,
project,
} => watch::run(watch::WatchOpts {
auto,
once,
interval,
to,
project,
})?,
}
Ok(())
}
Expand Down
80 changes: 79 additions & 1 deletion src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ pub fn serve() -> anyhow::Result<()> {
path: String,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FailoverOptParams {
/// Agent the session belongs to (e.g. "claude-code", "opencode").
agent: String,
/// Session id. Omit to use the agent's most recently active session —
/// called from inside a conversation, that is the calling session.
#[serde(default)]
session_id: Option<String>,
}

fn resolve_session(agent: Agent, session_id: Option<String>) -> Result<String, String> {
if let Some(id) = session_id {
return Ok(id);
}
let mut refs = crate::formats::list(agent);
refs.sort_by_key(|r| std::cmp::Reverse(r.mtime));
refs.first()
.map(|r| r.id.clone())
.ok_or_else(|| format!("no {agent} sessions found"))
}

#[tool_router]
impl BatonServer {
#[tool(description = "List coding-agent sessions across all detected agents, or filter by one. Returns one session per line: <agent>\\t<id>\\t<path>.")]
Expand Down Expand Up @@ -116,6 +137,63 @@ pub fn serve() -> anyhow::Result<()> {
}
}

#[tool(description = "Opt a session in to unattended failover: when `baton watch --auto` sees it die on a usage limit, it converts the transcript and resumes it on another agent. Omit session_id to opt in the calling session.")]
fn failover_opt_in(
&self,
Parameters(p): Parameters<FailoverOptParams>,
) -> String {
let Some(agent) = Agent::parse(&p.agent) else {
return format!("unknown agent: {}", p.agent);
};
let id = match resolve_session(agent, p.session_id) {
Ok(id) => id,
Err(e) => return e,
};
let key = crate::watch::State::key(agent, &id);
let mut state = crate::watch::State::load();
if !state.opt_in.contains(&key) {
state.opt_in.push(key.clone());
}
// Re-arm a death that fired before the opt-in, so `watch --auto`
// picks it up on the next scan instead of treating it as handled.
state.handled.remove(&key);
match state.save() {
Ok(()) => format!("opted in: {key}"),
Err(e) => format!("error: {e:#}"),
}
}

#[tool(description = "Remove a session from unattended failover. Omit session_id to opt out the calling session.")]
fn failover_opt_out(
&self,
Parameters(p): Parameters<FailoverOptParams>,
) -> String {
let Some(agent) = Agent::parse(&p.agent) else {
return format!("unknown agent: {}", p.agent);
};
let id = match resolve_session(agent, p.session_id) {
Ok(id) => id,
Err(e) => return e,
};
let key = crate::watch::State::key(agent, &id);
let mut state = crate::watch::State::load();
state.opt_in.retain(|k| k != &key);
match state.save() {
Ok(()) => format!("opted out: {key}"),
Err(e) => format!("error: {e:#}"),
}
}

#[tool(description = "List sessions opted in to unattended failover.")]
fn failover_status(&self) -> String {
let state = crate::watch::State::load();
if state.opt_in.is_empty() {
"no sessions opted in".to_string()
} else {
state.opt_in.join("\n")
}
}

#[tool(description = "Sniff a session file/dir and report which coding agent produced it.")]
fn detect_format(
&self,
Expand All @@ -140,7 +218,7 @@ pub fn serve() -> anyhow::Result<()> {
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Pass the baton between coding agents. Tools: list_sessions, convert_session, import_to_target, detect_format.",
"Pass the baton between coding agents. Tools: list_sessions, convert_session, import_to_target, detect_format, failover_opt_in, failover_opt_out, failover_status.",
)
}
}
Expand Down
Loading
Loading