@masyv/relay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ //! Handoff package builder — compresses session state into a prompt
2
+ //! that any agent can pick up and continue from.
3
+
4
+ use crate::SessionSnapshot;
5
+ use anyhow::Result;
6
+
7
+ /// Build a handoff prompt from a session snapshot.
8
+ /// This is the formatted text that gets sent to the fallback agent.
9
+ pub fn build_handoff(
10
+ snapshot: &SessionSnapshot,
11
+ target_agent: &str,
12
+ max_tokens: usize,
13
+ ) -> Result<String> {
14
+ let mut sections: Vec<String> = Vec::new();
15
+
16
+ // ── Header ─────────────────────────────────────────────────
17
+ let urgency = if let Some(ref deadline) = snapshot.deadline {
18
+ format!("\n DEADLINE : {deadline}")
19
+ } else {
20
+ String::new()
21
+ };
22
+
23
+ sections.push(format!(
24
+ "══ RELAY HANDOFF ══════════════════════════════
25
+ Original agent : Claude Code
26
+ Handed off at : {}
27
+ Target agent : {}
28
+ Project : {}{}
29
+ ══════════════════════════════════════════════",
30
+ snapshot.timestamp,
31
+ target_agent,
32
+ snapshot.project_dir,
33
+ urgency
34
+ ));
35
+
36
+ // ── Current Task ───────────────────────────────────────────
37
+ sections.push(format!(
38
+ "## CURRENT TASK\n\n{}",
39
+ snapshot.current_task
40
+ ));
41
+
42
+ // ── Todos ──────────────────────────────────────────────────
43
+ if !snapshot.todos.is_empty() {
44
+ let mut todo_text = String::from("## PROGRESS\n\n");
45
+ for t in &snapshot.todos {
46
+ let icon = match t.status.as_str() {
47
+ "completed" => "done",
48
+ "in_progress" => "IN PROGRESS",
49
+ _ => "pending",
50
+ };
51
+ todo_text.push_str(&format!(" [{}] {}\n", icon, t.content));
52
+ }
53
+ sections.push(todo_text);
54
+ }
55
+
56
+ // ── Last Error ─────────────────────────────────────────────
57
+ if let Some(ref err) = snapshot.last_error {
58
+ sections.push(format!(
59
+ "## LAST ERROR\n\n```\n{}\n```",
60
+ truncate_smart(err, 500)
61
+ ));
62
+ }
63
+
64
+ // ── Decisions ──────────────────────────────────────────────
65
+ if !snapshot.decisions.is_empty() {
66
+ let mut dec = String::from("## KEY DECISIONS\n\n");
67
+ for d in &snapshot.decisions {
68
+ dec.push_str(&format!(" - {d}\n"));
69
+ }
70
+ sections.push(dec);
71
+ }
72
+
73
+ // ── Git State ──────────────────────────────────────────────
74
+ if let Some(ref git) = snapshot.git_state {
75
+ let mut git_text = format!(
76
+ "## GIT STATE\n\n Branch: {}\n Status: {}",
77
+ git.branch, git.status_summary
78
+ );
79
+ if !git.recent_commits.is_empty() {
80
+ git_text.push_str("\n\n Recent commits:\n");
81
+ for c in git.recent_commits.iter().take(5) {
82
+ git_text.push_str(&format!(" {c}\n"));
83
+ }
84
+ }
85
+ if !git.diff_summary.is_empty() {
86
+ git_text.push_str(&format!(
87
+ "\n Diff summary:\n {}",
88
+ truncate_smart(&git.diff_summary, 500)
89
+ ));
90
+ }
91
+ sections.push(git_text);
92
+ }
93
+
94
+ // ── Recent Files ───────────────────────────────────────────
95
+ if !snapshot.recent_files.is_empty() {
96
+ let mut files = String::from("## RECENTLY CHANGED FILES\n\n");
97
+ for f in snapshot.recent_files.iter().take(20) {
98
+ files.push_str(&format!(" {f}\n"));
99
+ }
100
+ sections.push(files);
101
+ }
102
+
103
+ // ── Instructions for agent ─────────────────────────────────
104
+ let instructions = format!(
105
+ "## INSTRUCTIONS\n\n\
106
+ You are continuing work that was started in a Claude Code session.\n\
107
+ The session was interrupted by a rate limit.\n\
108
+ Pick up EXACTLY where it left off. Do NOT re-explain context.\n\
109
+ The user is waiting — be efficient and direct.\n\
110
+ \n\
111
+ Working directory: {}\n\
112
+ {}",
113
+ snapshot.project_dir,
114
+ if snapshot.deadline.is_some() {
115
+ "THERE IS A DEADLINE. Prioritise completing the current task."
116
+ } else { "" }
117
+ );
118
+ sections.push(instructions);
119
+
120
+ let mut full = sections.join("\n\n");
121
+
122
+ // Truncate if too long (rough token estimate: chars / 3.5)
123
+ let estimated_tokens = full.len() as f64 / 3.5;
124
+ if estimated_tokens > max_tokens as f64 {
125
+ let max_chars = (max_tokens as f64 * 3.5) as usize;
126
+ full.truncate(max_chars);
127
+ full.push_str("\n\n[...truncated to fit context limit]");
128
+ }
129
+
130
+ Ok(full)
131
+ }
132
+
133
+ /// Save handoff to a file for reference/debugging.
134
+ pub fn save_handoff(handoff: &str, project_dir: &std::path::Path) -> Result<std::path::PathBuf> {
135
+ let relay_dir = project_dir.join(".relay");
136
+ std::fs::create_dir_all(&relay_dir)?;
137
+
138
+ let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
139
+ let path = relay_dir.join(format!("handoff_{ts}.md"));
140
+ std::fs::write(&path, handoff)?;
141
+
142
+ Ok(path)
143
+ }
144
+
145
+ fn truncate_smart(s: &str, max: usize) -> String {
146
+ if s.len() <= max {
147
+ return s.to_string();
148
+ }
149
+ // Try to cut at a line boundary
150
+ let cut = &s[..max];
151
+ if let Some(last_nl) = cut.rfind('\n') {
152
+ format!("{}\n[...truncated]", &s[..last_nl])
153
+ } else {
154
+ format!("{}...", &s[..max])
155
+ }
156
+ }
@@ -0,0 +1,198 @@
1
+ pub mod agents;
2
+ pub mod capture;
3
+ pub mod detect;
4
+ pub mod handoff;
5
+
6
+ use serde::{Deserialize, Serialize};
7
+ use std::path::PathBuf;
8
+
9
+ // ─── Config ──────────────────────────────────────────────────────────────────
10
+
11
+ /// Relay configuration — loaded from ~/.relay/config.toml
12
+ #[derive(Debug, Clone, Serialize, Deserialize)]
13
+ pub struct Config {
14
+ #[serde(default)]
15
+ pub general: GeneralConfig,
16
+ #[serde(default)]
17
+ pub agents: AgentsConfig,
18
+ }
19
+
20
+ #[derive(Debug, Clone, Serialize, Deserialize)]
21
+ pub struct GeneralConfig {
22
+ /// Priority order for fallback agents
23
+ #[serde(default = "default_priority")]
24
+ pub priority: Vec<String>,
25
+ /// Max tokens for handoff context
26
+ #[serde(default = "default_max_context")]
27
+ pub max_context_tokens: usize,
28
+ /// Auto-handoff on rate limit detection
29
+ #[serde(default = "default_true")]
30
+ pub auto_handoff: bool,
31
+ }
32
+
33
+ impl Default for GeneralConfig {
34
+ fn default() -> Self {
35
+ Self {
36
+ priority: default_priority(),
37
+ max_context_tokens: 8000,
38
+ auto_handoff: true,
39
+ }
40
+ }
41
+ }
42
+
43
+ fn default_priority() -> Vec<String> {
44
+ vec![
45
+ "codex".into(),
46
+ "gemini".into(),
47
+ "ollama".into(),
48
+ "openai".into(),
49
+ ]
50
+ }
51
+ fn default_max_context() -> usize { 8000 }
52
+ fn default_true() -> bool { true }
53
+
54
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
55
+ pub struct AgentsConfig {
56
+ #[serde(default)]
57
+ pub codex: CodexConfig,
58
+ #[serde(default)]
59
+ pub gemini: GeminiConfig,
60
+ #[serde(default)]
61
+ pub ollama: OllamaConfig,
62
+ #[serde(default)]
63
+ pub openai: OpenAIConfig,
64
+ }
65
+
66
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
67
+ pub struct CodexConfig {
68
+ /// Path to codex CLI binary (default: search PATH)
69
+ pub binary: Option<String>,
70
+ /// Model to use
71
+ #[serde(default = "codex_default_model")]
72
+ pub model: String,
73
+ }
74
+ fn codex_default_model() -> String { "o4-mini".into() }
75
+
76
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
77
+ pub struct GeminiConfig {
78
+ pub api_key: Option<String>,
79
+ #[serde(default = "gemini_default_model")]
80
+ pub model: String,
81
+ }
82
+ fn gemini_default_model() -> String { "gemini-2.5-pro".into() }
83
+
84
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
85
+ pub struct OllamaConfig {
86
+ #[serde(default = "ollama_default_url")]
87
+ pub url: String,
88
+ #[serde(default = "ollama_default_model")]
89
+ pub model: String,
90
+ }
91
+ fn ollama_default_url() -> String { "http://localhost:11434".into() }
92
+ fn ollama_default_model() -> String { "llama3".into() }
93
+
94
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
95
+ pub struct OpenAIConfig {
96
+ pub api_key: Option<String>,
97
+ #[serde(default = "openai_default_model")]
98
+ pub model: String,
99
+ }
100
+ fn openai_default_model() -> String { "gpt-4o".into() }
101
+
102
+ impl Config {
103
+ pub fn load() -> anyhow::Result<Self> {
104
+ let path = config_path();
105
+ if path.exists() {
106
+ let text = std::fs::read_to_string(&path)?;
107
+ Ok(toml::from_str(&text)?)
108
+ } else {
109
+ Ok(Self {
110
+ general: GeneralConfig::default(),
111
+ agents: AgentsConfig::default(),
112
+ })
113
+ }
114
+ }
115
+
116
+ pub fn save_default(path: &std::path::Path) -> anyhow::Result<()> {
117
+ let cfg = Self {
118
+ general: GeneralConfig::default(),
119
+ agents: AgentsConfig::default(),
120
+ };
121
+ let text = toml::to_string_pretty(&cfg)?;
122
+ if let Some(parent) = path.parent() {
123
+ std::fs::create_dir_all(parent)?;
124
+ }
125
+ std::fs::write(path, text)?;
126
+ Ok(())
127
+ }
128
+ }
129
+
130
+ pub fn config_path() -> PathBuf {
131
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
132
+ PathBuf::from(home).join(".relay/config.toml")
133
+ }
134
+
135
+ pub fn data_dir() -> PathBuf {
136
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
137
+ PathBuf::from(home).join(".relay")
138
+ }
139
+
140
+ // ─── Core Types ──────────────────────────────────────────────────────────────
141
+
142
+ /// The state of a session at time of handoff.
143
+ #[derive(Debug, Clone, Serialize, Deserialize)]
144
+ pub struct SessionSnapshot {
145
+ /// What the user was working on (extracted from conversation)
146
+ pub current_task: String,
147
+ /// Todo items and their states
148
+ pub todos: Vec<TodoItem>,
149
+ /// Key decisions made in the session
150
+ pub decisions: Vec<String>,
151
+ /// Recent errors or blockers
152
+ pub last_error: Option<String>,
153
+ /// Last tool output (truncated)
154
+ pub last_output: Option<String>,
155
+ /// Git state: branch, diff summary, recent commits
156
+ pub git_state: Option<GitState>,
157
+ /// Project directory
158
+ pub project_dir: String,
159
+ /// Files recently edited
160
+ pub recent_files: Vec<String>,
161
+ /// When the snapshot was taken
162
+ pub timestamp: String,
163
+ /// Deadline if set
164
+ pub deadline: Option<String>,
165
+ }
166
+
167
+ #[derive(Debug, Clone, Serialize, Deserialize)]
168
+ pub struct TodoItem {
169
+ pub content: String,
170
+ pub status: String, // pending, in_progress, completed
171
+ }
172
+
173
+ #[derive(Debug, Clone, Serialize, Deserialize)]
174
+ pub struct GitState {
175
+ pub branch: String,
176
+ pub status_summary: String,
177
+ pub recent_commits: Vec<String>,
178
+ pub diff_summary: String,
179
+ pub uncommitted_files: Vec<String>,
180
+ }
181
+
182
+ /// Result of a handoff attempt.
183
+ #[derive(Debug, Clone, Serialize, Deserialize)]
184
+ pub struct HandoffResult {
185
+ pub agent: String,
186
+ pub success: bool,
187
+ pub message: String,
188
+ pub handoff_file: Option<String>,
189
+ }
190
+
191
+ /// Agent availability status.
192
+ #[derive(Debug, Clone, Serialize, Deserialize)]
193
+ pub struct AgentStatus {
194
+ pub name: String,
195
+ pub available: bool,
196
+ pub reason: String,
197
+ pub version: Option<String>,
198
+ }
@@ -0,0 +1,331 @@
1
+ use anyhow::Result;
2
+ use clap::{Parser, Subcommand};
3
+ use colored::Colorize;
4
+ use std::path::PathBuf;
5
+
6
+ use relay::{agents, capture, handoff, Config};
7
+
8
+ #[derive(Parser)]
9
+ #[command(
10
+ name = "relay",
11
+ about = "Relay — When Claude's rate limit hits, another agent picks up where you left off.",
12
+ long_about = "Captures your Claude Code session state (task, todos, git diff, decisions,\nerrors) and hands it off to Codex, Gemini, Ollama, or GPT-4 — so your\nwork never stops.",
13
+ version
14
+ )]
15
+ struct Cli {
16
+ #[command(subcommand)]
17
+ command: Commands,
18
+
19
+ /// Output as JSON
20
+ #[arg(long, global = true)]
21
+ json: bool,
22
+
23
+ /// Verbose logging
24
+ #[arg(long, short, global = true)]
25
+ verbose: bool,
26
+
27
+ /// Project directory
28
+ #[arg(long, global = true)]
29
+ project: Option<PathBuf>,
30
+ }
31
+
32
+ #[derive(Subcommand)]
33
+ enum Commands {
34
+ /// Hand off current session to a fallback agent right now
35
+ Handoff {
36
+ /// Force a specific agent (codex, gemini, ollama, openai)
37
+ #[arg(long)]
38
+ to: Option<String>,
39
+
40
+ /// Set deadline urgency (e.g. "7pm", "19:00", "30min")
41
+ #[arg(long)]
42
+ deadline: Option<String>,
43
+
44
+ /// Don't execute — just print the handoff package
45
+ #[arg(long)]
46
+ dry_run: bool,
47
+ },
48
+
49
+ /// Show current session snapshot (what would be handed off)
50
+ Status,
51
+
52
+ /// List configured agents and their availability
53
+ Agents,
54
+
55
+ /// Generate default config file at ~/.relay/config.toml
56
+ Init,
57
+
58
+ /// PostToolUse hook mode (auto-detect rate limits from stdin)
59
+ Hook {
60
+ /// Session ID
61
+ #[arg(long, default_value = "unknown")]
62
+ session: String,
63
+ },
64
+ }
65
+
66
+ fn main() -> Result<()> {
67
+ let cli = Cli::parse();
68
+
69
+ let filter = if cli.verbose { "debug" } else { "warn" };
70
+ tracing_subscriber::fmt()
71
+ .with_env_filter(
72
+ tracing_subscriber::EnvFilter::try_from_default_env()
73
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
74
+ )
75
+ .with_target(false)
76
+ .with_writer(std::io::stderr)
77
+ .init();
78
+
79
+ let project_dir = cli.project
80
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
81
+ let config = Config::load().unwrap_or_else(|_| Config {
82
+ general: Default::default(),
83
+ agents: Default::default(),
84
+ });
85
+
86
+ match cli.command {
87
+ Commands::Handoff { to, deadline, dry_run } => {
88
+ eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
89
+
90
+ let snapshot = capture::capture_snapshot(
91
+ &project_dir,
92
+ deadline.as_deref(),
93
+ )?;
94
+
95
+ let target = to.as_deref().unwrap_or("auto");
96
+ let handoff_text = handoff::build_handoff(
97
+ &snapshot,
98
+ target,
99
+ config.general.max_context_tokens,
100
+ )?;
101
+
102
+ // Save handoff file for reference
103
+ let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
104
+
105
+ if dry_run || cli.json {
106
+ if cli.json {
107
+ let result = serde_json::json!({
108
+ "snapshot": snapshot,
109
+ "handoff_text": handoff_text,
110
+ "handoff_file": handoff_path.to_string_lossy(),
111
+ "target_agent": target,
112
+ });
113
+ println!("{}", serde_json::to_string_pretty(&result)?);
114
+ } else {
115
+ println!("{handoff_text}");
116
+ eprintln!();
117
+ eprintln!("{}", format!("📄 Saved to: {}", handoff_path.display()).dimmed());
118
+ }
119
+ return Ok(());
120
+ }
121
+
122
+ eprintln!("{}", format!("📄 Handoff saved: {}", handoff_path.display()).dimmed());
123
+ eprintln!();
124
+
125
+ // Execute handoff
126
+ let result = if let Some(ref agent_name) = to {
127
+ agents::handoff_to_named(&config, agent_name, &handoff_text, &project_dir.to_string_lossy())
128
+ } else {
129
+ agents::handoff_to_first_available(&config, &handoff_text, &project_dir.to_string_lossy())
130
+ }?;
131
+
132
+ if result.success {
133
+ eprintln!("{}", format!("✅ Handed off to {}", result.agent).green().bold());
134
+ eprintln!(" {}", result.message);
135
+ } else {
136
+ eprintln!("{}", format!("❌ Handoff failed: {}", result.message).red());
137
+ eprintln!();
138
+ eprintln!("💡 The handoff context was saved to:");
139
+ eprintln!(" {}", handoff_path.display());
140
+ eprintln!(" You can copy-paste it into any AI assistant manually.");
141
+ }
142
+ }
143
+
144
+ Commands::Status => {
145
+ let snapshot = capture::capture_snapshot(&project_dir, None)?;
146
+
147
+ if cli.json {
148
+ println!("{}", serde_json::to_string_pretty(&snapshot)?);
149
+ return Ok(());
150
+ }
151
+
152
+ println!("{}", "═══ Relay Session Snapshot ═══".bold());
153
+ println!();
154
+ println!("{}: {}", "Project".bold(), snapshot.project_dir);
155
+ println!("{}: {}", "Captured".bold(), snapshot.timestamp);
156
+ println!();
157
+
158
+ println!("{}", "── Current Task ──".cyan());
159
+ println!(" {}", snapshot.current_task);
160
+ println!();
161
+
162
+ if !snapshot.todos.is_empty() {
163
+ println!("{}", "── Todos ──".cyan());
164
+ for t in &snapshot.todos {
165
+ let icon = match t.status.as_str() {
166
+ "completed" => "✅",
167
+ "in_progress" => "🔄",
168
+ _ => "⏳",
169
+ };
170
+ println!(" {icon} [{}] {}", t.status, t.content);
171
+ }
172
+ println!();
173
+ }
174
+
175
+ if let Some(ref err) = snapshot.last_error {
176
+ println!("{}", "── Last Error ──".red());
177
+ println!(" {err}");
178
+ println!();
179
+ }
180
+
181
+ if !snapshot.decisions.is_empty() {
182
+ println!("{}", "── Decisions ──".cyan());
183
+ for d in &snapshot.decisions {
184
+ println!(" • {d}");
185
+ }
186
+ println!();
187
+ }
188
+
189
+ if let Some(ref git) = snapshot.git_state {
190
+ println!("{}", "── Git ──".cyan());
191
+ println!(" Branch: {}", git.branch);
192
+ println!(" {}", git.status_summary);
193
+ if !git.recent_commits.is_empty() {
194
+ println!(" Recent:");
195
+ for c in git.recent_commits.iter().take(3) {
196
+ println!(" {c}");
197
+ }
198
+ }
199
+ println!();
200
+ }
201
+
202
+ if !snapshot.recent_files.is_empty() {
203
+ println!("{}", "── Changed Files ──".cyan());
204
+ for f in snapshot.recent_files.iter().take(10) {
205
+ println!(" {f}");
206
+ }
207
+ println!();
208
+ }
209
+ }
210
+
211
+ Commands::Agents => {
212
+ let statuses = agents::check_all_agents(&config);
213
+
214
+ if cli.json {
215
+ println!("{}", serde_json::to_string_pretty(&statuses)?);
216
+ return Ok(());
217
+ }
218
+
219
+ println!("{}", "═══ Relay Agents ═══".bold());
220
+ println!();
221
+ println!("Priority order: {}", config.general.priority.join(" → "));
222
+ println!();
223
+
224
+ for s in &statuses {
225
+ let icon = if s.available { "✅" } else { "❌" };
226
+ let name = if s.available {
227
+ s.name.green().bold().to_string()
228
+ } else {
229
+ s.name.dimmed().to_string()
230
+ };
231
+ println!(
232
+ " {icon} {:<10} {}",
233
+ name,
234
+ s.reason
235
+ );
236
+ if let Some(ref v) = s.version {
237
+ println!(" Version: {v}");
238
+ }
239
+ }
240
+ println!();
241
+
242
+ let available = statuses.iter().filter(|s| s.available).count();
243
+ if available == 0 {
244
+ eprintln!("{}", "⚠️ No agents available. Run 'relay init' to configure.".yellow());
245
+ } else {
246
+ println!(
247
+ " {} agent{} ready for handoff.",
248
+ available,
249
+ if available == 1 { "" } else { "s" }
250
+ );
251
+ }
252
+ }
253
+
254
+ Commands::Init => {
255
+ let path = relay::config_path();
256
+ if path.exists() {
257
+ println!("Config already exists at: {}", path.display());
258
+ println!("Edit it to add API keys and customize agent priority.");
259
+ } else {
260
+ Config::save_default(&path)?;
261
+ println!("{}", "✅ Config created at:".green());
262
+ println!(" {}", path.display());
263
+ println!();
264
+ println!("Edit it to add API keys:");
265
+ println!(" [agents.gemini]");
266
+ println!(" api_key = \"your-gemini-key\"");
267
+ println!();
268
+ println!(" [agents.openai]");
269
+ println!(" api_key = \"your-openai-key\"");
270
+ }
271
+ }
272
+
273
+ Commands::Hook { session: _ } => {
274
+ use std::io::Read;
275
+ let mut raw = String::new();
276
+ std::io::stdin().read_to_string(&mut raw)?;
277
+
278
+ // Check for rate limit signals
279
+ if let Some(detection) = relay::detect::check_hook_output(&raw) {
280
+ eprintln!(
281
+ "{}",
282
+ format!(
283
+ "🚨 [relay] Rate limit detected in {} output (signal: {})",
284
+ detection.tool_name, detection.signal
285
+ ).red().bold()
286
+ );
287
+
288
+ if config.general.auto_handoff {
289
+ // Auto-handoff
290
+ let snapshot = capture::capture_snapshot(&project_dir, None)?;
291
+ let handoff_text = handoff::build_handoff(
292
+ &snapshot,
293
+ "auto",
294
+ config.general.max_context_tokens,
295
+ )?;
296
+
297
+ let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
298
+ eprintln!(
299
+ "📄 Handoff saved: {}",
300
+ handoff_path.display()
301
+ );
302
+
303
+ let result = agents::handoff_to_first_available(
304
+ &config,
305
+ &handoff_text,
306
+ &project_dir.to_string_lossy(),
307
+ )?;
308
+
309
+ if result.success {
310
+ eprintln!(
311
+ "{}",
312
+ format!("✅ Auto-handed off to {}", result.agent).green()
313
+ );
314
+ } else {
315
+ eprintln!(
316
+ "{}",
317
+ format!("⚠️ No agents available. Handoff saved to: {}",
318
+ handoff_path.display()
319
+ ).yellow()
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ // Always pass through the original output
326
+ print!("{raw}");
327
+ }
328
+ }
329
+
330
+ Ok(())
331
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Relay PostToolUse hook — detects rate limits and auto-hands off to fallback agent.
3
+ set -euo pipefail
4
+
5
+ RELAY="${RELAY_BIN:-relay}"
6
+ if ! command -v "$RELAY" &>/dev/null; then
7
+ for candidate in "$HOME/.cargo/bin/relay" "/usr/local/bin/relay" \
8
+ "$(dirname "$0")/../core/target/release/relay"; do
9
+ [[ -x "$candidate" ]] && { RELAY="$candidate"; break; }
10
+ done
11
+ fi
12
+
13
+ INPUT=$(cat)
14
+
15
+ if command -v "$RELAY" &>/dev/null || [[ -x "$RELAY" ]]; then
16
+ echo "$INPUT" | "$RELAY" hook --session "${CLAUDE_SESSION_ID:-default}" 2>/dev/null || echo "$INPUT"
17
+ else
18
+ echo "$INPUT"
19
+ fi