@masyv/relay 0.4.0 → 1.0.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.
- package/README.md +166 -90
- package/core/Cargo.toml +5 -2
- package/core/src/diff.rs +118 -0
- package/core/src/handoff/mod.rs +2 -0
- package/core/src/handoff/templates.rs +74 -0
- package/core/src/history.rs +107 -0
- package/core/src/lib.rs +4 -0
- package/core/src/main.rs +258 -209
- package/core/src/resume.rs +202 -0
- package/core/src/tui.rs +324 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//! `relay resume` — When Claude comes back, show what happened during the handoff.
|
|
2
|
+
//! Reads the latest handoff file + git diff since handoff time → generates a
|
|
3
|
+
//! "welcome back" prompt so Claude (or any agent) can pick up with full awareness
|
|
4
|
+
//! of what the fallback agent did.
|
|
5
|
+
|
|
6
|
+
use anyhow::{Context, Result};
|
|
7
|
+
use std::path::Path;
|
|
8
|
+
|
|
9
|
+
#[derive(Debug, serde::Serialize)]
|
|
10
|
+
pub struct ResumeReport {
|
|
11
|
+
pub handoff_file: String,
|
|
12
|
+
pub handoff_time: String,
|
|
13
|
+
pub original_task: String,
|
|
14
|
+
pub changes_since: Vec<String>,
|
|
15
|
+
pub new_commits: Vec<String>,
|
|
16
|
+
pub diff_stat: String,
|
|
17
|
+
pub resume_prompt: String,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Build a resume report from the latest handoff.
|
|
21
|
+
pub fn build_resume(project_dir: &Path) -> Result<ResumeReport> {
|
|
22
|
+
let relay_dir = project_dir.join(".relay");
|
|
23
|
+
if !relay_dir.exists() {
|
|
24
|
+
anyhow::bail!("No .relay/ directory found. Run 'relay handoff' first.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Find latest handoff file
|
|
28
|
+
let handoff_file = find_latest_handoff(&relay_dir)?;
|
|
29
|
+
let handoff_content = std::fs::read_to_string(&handoff_file)
|
|
30
|
+
.context("Failed to read handoff file")?;
|
|
31
|
+
|
|
32
|
+
// Extract timestamp from filename: handoff_20260405_141328.md
|
|
33
|
+
let filename = handoff_file
|
|
34
|
+
.file_name()
|
|
35
|
+
.unwrap_or_default()
|
|
36
|
+
.to_string_lossy();
|
|
37
|
+
let handoff_time = parse_handoff_timestamp(&filename);
|
|
38
|
+
|
|
39
|
+
// Extract original task from handoff content
|
|
40
|
+
let original_task = extract_section(&handoff_content, "## CURRENT TASK")
|
|
41
|
+
.unwrap_or_else(|| "Unknown".into());
|
|
42
|
+
|
|
43
|
+
// Git changes since handoff
|
|
44
|
+
let changes_since = git_changed_files(project_dir, &handoff_time);
|
|
45
|
+
let new_commits = git_commits_since(project_dir, &handoff_time);
|
|
46
|
+
let diff_stat = git_diff_stat(project_dir, &handoff_time);
|
|
47
|
+
|
|
48
|
+
// Build resume prompt
|
|
49
|
+
let resume_prompt = build_resume_prompt(
|
|
50
|
+
&original_task,
|
|
51
|
+
&changes_since,
|
|
52
|
+
&new_commits,
|
|
53
|
+
&diff_stat,
|
|
54
|
+
&handoff_time,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
Ok(ResumeReport {
|
|
58
|
+
handoff_file: handoff_file.to_string_lossy().to_string(),
|
|
59
|
+
handoff_time,
|
|
60
|
+
original_task,
|
|
61
|
+
changes_since,
|
|
62
|
+
new_commits,
|
|
63
|
+
diff_stat,
|
|
64
|
+
resume_prompt,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn find_latest_handoff(relay_dir: &Path) -> Result<std::path::PathBuf> {
|
|
69
|
+
let mut latest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
|
|
70
|
+
for entry in std::fs::read_dir(relay_dir)? {
|
|
71
|
+
let entry = entry?;
|
|
72
|
+
let path = entry.path();
|
|
73
|
+
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
|
74
|
+
if name.starts_with("handoff_") && name.ends_with(".md") {
|
|
75
|
+
if let Ok(meta) = path.metadata() {
|
|
76
|
+
if let Ok(modified) = meta.modified() {
|
|
77
|
+
if latest.as_ref().map_or(true, |(_, t)| modified > *t) {
|
|
78
|
+
latest = Some((path, modified));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
latest
|
|
85
|
+
.map(|(p, _)| p)
|
|
86
|
+
.ok_or_else(|| anyhow::anyhow!("No handoff files found in .relay/"))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn parse_handoff_timestamp(filename: &str) -> String {
|
|
90
|
+
// handoff_20260405_141328.md → 2026-04-05 14:13:28
|
|
91
|
+
let ts = filename
|
|
92
|
+
.strip_prefix("handoff_")
|
|
93
|
+
.unwrap_or("")
|
|
94
|
+
.strip_suffix(".md")
|
|
95
|
+
.unwrap_or("");
|
|
96
|
+
if ts.len() >= 15 {
|
|
97
|
+
format!(
|
|
98
|
+
"{}-{}-{} {}:{}:{}",
|
|
99
|
+
&ts[0..4], &ts[4..6], &ts[6..8],
|
|
100
|
+
&ts[9..11], &ts[11..13], &ts[13..15]
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
"unknown".into()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn extract_section(content: &str, header: &str) -> Option<String> {
|
|
108
|
+
let start = content.find(header)?;
|
|
109
|
+
let after = &content[start + header.len()..];
|
|
110
|
+
let end = after.find("\n## ").unwrap_or(after.len());
|
|
111
|
+
let section = after[..end].trim();
|
|
112
|
+
if section.is_empty() { None } else { Some(section.to_string()) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn git_changed_files(dir: &Path, _since: &str) -> Vec<String> {
|
|
116
|
+
let output = std::process::Command::new("git")
|
|
117
|
+
.current_dir(dir)
|
|
118
|
+
.args(["diff", "--name-only", "HEAD"])
|
|
119
|
+
.output()
|
|
120
|
+
.ok();
|
|
121
|
+
output
|
|
122
|
+
.filter(|o| o.status.success())
|
|
123
|
+
.map(|o| {
|
|
124
|
+
String::from_utf8_lossy(&o.stdout)
|
|
125
|
+
.lines()
|
|
126
|
+
.filter(|l| !l.is_empty())
|
|
127
|
+
.map(String::from)
|
|
128
|
+
.collect()
|
|
129
|
+
})
|
|
130
|
+
.unwrap_or_default()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fn git_commits_since(dir: &Path, since: &str) -> Vec<String> {
|
|
134
|
+
let output = std::process::Command::new("git")
|
|
135
|
+
.current_dir(dir)
|
|
136
|
+
.args(["log", "--oneline", "-10", "--since", since])
|
|
137
|
+
.output()
|
|
138
|
+
.ok();
|
|
139
|
+
output
|
|
140
|
+
.filter(|o| o.status.success())
|
|
141
|
+
.map(|o| {
|
|
142
|
+
String::from_utf8_lossy(&o.stdout)
|
|
143
|
+
.lines()
|
|
144
|
+
.filter(|l| !l.is_empty())
|
|
145
|
+
.map(String::from)
|
|
146
|
+
.collect()
|
|
147
|
+
})
|
|
148
|
+
.unwrap_or_default()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fn git_diff_stat(dir: &Path, _since: &str) -> String {
|
|
152
|
+
let output = std::process::Command::new("git")
|
|
153
|
+
.current_dir(dir)
|
|
154
|
+
.args(["diff", "--stat", "HEAD"])
|
|
155
|
+
.output()
|
|
156
|
+
.ok();
|
|
157
|
+
output
|
|
158
|
+
.filter(|o| o.status.success())
|
|
159
|
+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
160
|
+
.unwrap_or_default()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn build_resume_prompt(
|
|
164
|
+
task: &str,
|
|
165
|
+
changed: &[String],
|
|
166
|
+
commits: &[String],
|
|
167
|
+
diff_stat: &str,
|
|
168
|
+
handoff_time: &str,
|
|
169
|
+
) -> String {
|
|
170
|
+
let mut prompt = format!(
|
|
171
|
+
"══ RELAY RESUME ══════════════════════════════\n\
|
|
172
|
+
You are resuming a session that was handed off at {handoff_time}.\n\
|
|
173
|
+
A fallback agent continued the work. Here's what happened:\n\
|
|
174
|
+
══════════════════════════════════════════════\n\n\
|
|
175
|
+
## ORIGINAL TASK\n\n{task}\n"
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if !commits.is_empty() {
|
|
179
|
+
prompt.push_str("\n## COMMITS MADE DURING HANDOFF\n\n");
|
|
180
|
+
for c in commits {
|
|
181
|
+
prompt.push_str(&format!(" {c}\n"));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if !changed.is_empty() {
|
|
186
|
+
prompt.push_str(&format!("\n## FILES CHANGED ({})\n\n", changed.len()));
|
|
187
|
+
for f in changed.iter().take(20) {
|
|
188
|
+
prompt.push_str(&format!(" {f}\n"));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if !diff_stat.is_empty() {
|
|
193
|
+
prompt.push_str(&format!("\n## DIFF SUMMARY\n\n{diff_stat}\n"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
prompt.push_str("\n## INSTRUCTIONS\n\n\
|
|
197
|
+
Review what the fallback agent did above.\n\
|
|
198
|
+
Continue from the current state. Check if the original task is complete.\n\
|
|
199
|
+
If not, finish it.\n");
|
|
200
|
+
|
|
201
|
+
prompt
|
|
202
|
+
}
|
package/core/src/tui.rs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
//! Beautiful terminal UI for Relay — spinners, boxes, interactive prompts.
|
|
2
|
+
|
|
3
|
+
use colored::Colorize;
|
|
4
|
+
use console::Term;
|
|
5
|
+
use indicatif::{ProgressBar, ProgressStyle};
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
|
|
8
|
+
// ─── Banner ─────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
pub fn print_banner() {
|
|
11
|
+
let banner = r#"
|
|
12
|
+
╔═══════════════════════════════════════════════╗
|
|
13
|
+
║ ║
|
|
14
|
+
║ ⚡ R E L A Y ║
|
|
15
|
+
║ Cross-agent context handoff ║
|
|
16
|
+
║ ║
|
|
17
|
+
╚═══════════════════════════════════════════════╝
|
|
18
|
+
"#;
|
|
19
|
+
eprintln!("{}", banner.cyan());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Spinners ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
pub fn spinner(msg: &str) -> ProgressBar {
|
|
25
|
+
let pb = ProgressBar::new_spinner();
|
|
26
|
+
pb.set_style(
|
|
27
|
+
ProgressStyle::with_template(" {spinner:.cyan} {msg}")
|
|
28
|
+
.unwrap()
|
|
29
|
+
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]),
|
|
30
|
+
);
|
|
31
|
+
pb.set_message(msg.to_string());
|
|
32
|
+
pb.enable_steady_tick(Duration::from_millis(80));
|
|
33
|
+
pb
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn step(num: usize, total: usize, msg: &str) -> ProgressBar {
|
|
37
|
+
let pb = ProgressBar::new_spinner();
|
|
38
|
+
pb.set_style(
|
|
39
|
+
ProgressStyle::with_template(&format!(
|
|
40
|
+
" {{spinner:.cyan}} [{}/{}] {{msg}}",
|
|
41
|
+
num, total
|
|
42
|
+
))
|
|
43
|
+
.unwrap()
|
|
44
|
+
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]),
|
|
45
|
+
);
|
|
46
|
+
pb.set_message(msg.to_string());
|
|
47
|
+
pb.enable_steady_tick(Duration::from_millis(80));
|
|
48
|
+
pb
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Boxes ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
pub fn print_box(title: &str, content: &str) {
|
|
54
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
55
|
+
let width = term_width.min(72).max(40);
|
|
56
|
+
let inner = width - 4;
|
|
57
|
+
|
|
58
|
+
// Top border
|
|
59
|
+
eprintln!(" ╭{}╮", "─".repeat(inner + 2));
|
|
60
|
+
|
|
61
|
+
// Title
|
|
62
|
+
let title_padded = format!(" {} ", title);
|
|
63
|
+
let pad = inner.saturating_sub(title_padded.len()) + 1;
|
|
64
|
+
eprintln!(" │{}{}│", title_padded.bold().cyan(), " ".repeat(pad));
|
|
65
|
+
|
|
66
|
+
// Separator
|
|
67
|
+
eprintln!(" ├{}┤", "─".repeat(inner + 2));
|
|
68
|
+
|
|
69
|
+
// Content lines
|
|
70
|
+
for line in content.lines() {
|
|
71
|
+
let display_line = if line.len() > inner {
|
|
72
|
+
let mut end = inner.saturating_sub(1);
|
|
73
|
+
while end > 0 && !line.is_char_boundary(end) { end -= 1; }
|
|
74
|
+
format!("{}…", &line[..end])
|
|
75
|
+
} else {
|
|
76
|
+
line.to_string()
|
|
77
|
+
};
|
|
78
|
+
let pad = inner.saturating_sub(display_line.len()) + 1;
|
|
79
|
+
eprintln!(" │ {}{} │", display_line, " ".repeat(pad.saturating_sub(1)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bottom border
|
|
83
|
+
eprintln!(" ╰{}╯", "─".repeat(inner + 2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn print_section(icon: &str, title: &str) {
|
|
87
|
+
eprintln!();
|
|
88
|
+
eprintln!(" {} {}", icon, title.bold());
|
|
89
|
+
eprintln!(" {}", "─".repeat(50).dimmed());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Agent Select ───────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
pub fn select_agent(agents: &[(String, bool, String)]) -> Option<String> {
|
|
95
|
+
let items: Vec<String> = agents
|
|
96
|
+
.iter()
|
|
97
|
+
.map(|(name, available, reason)| {
|
|
98
|
+
if *available {
|
|
99
|
+
format!("✅ {} — {}", name, reason)
|
|
100
|
+
} else {
|
|
101
|
+
format!("❌ {} — {}", name, reason)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.collect();
|
|
105
|
+
|
|
106
|
+
eprintln!();
|
|
107
|
+
let selection = dialoguer::FuzzySelect::with_theme(
|
|
108
|
+
&dialoguer::theme::ColorfulTheme::default(),
|
|
109
|
+
)
|
|
110
|
+
.with_prompt(" Select target agent")
|
|
111
|
+
.items(&items)
|
|
112
|
+
.default(0)
|
|
113
|
+
.interact_opt()
|
|
114
|
+
.ok()
|
|
115
|
+
.flatten()?;
|
|
116
|
+
|
|
117
|
+
let (name, available, _) = &agents[selection];
|
|
118
|
+
if !*available {
|
|
119
|
+
eprintln!(
|
|
120
|
+
"\n {} {} is not available.",
|
|
121
|
+
"⚠️ ".yellow(),
|
|
122
|
+
name.bold()
|
|
123
|
+
);
|
|
124
|
+
return None;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Some(name.clone())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Status Display ─────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
pub fn print_snapshot(snapshot: &crate::SessionSnapshot) {
|
|
133
|
+
eprintln!();
|
|
134
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
135
|
+
let width = term_width.min(72).max(40);
|
|
136
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
137
|
+
eprintln!(
|
|
138
|
+
" {} {}",
|
|
139
|
+
"📋".to_string(),
|
|
140
|
+
"Session Snapshot".bold().cyan()
|
|
141
|
+
);
|
|
142
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
143
|
+
|
|
144
|
+
// Project + time
|
|
145
|
+
eprintln!();
|
|
146
|
+
eprintln!(" {} {}", "📁", snapshot.project_dir.dimmed());
|
|
147
|
+
eprintln!(" {} {}", "🕐", snapshot.timestamp.dimmed());
|
|
148
|
+
|
|
149
|
+
// Current task
|
|
150
|
+
print_section("🎯", "Current Task");
|
|
151
|
+
eprintln!(" {}", snapshot.current_task);
|
|
152
|
+
|
|
153
|
+
// Todos
|
|
154
|
+
if !snapshot.todos.is_empty() {
|
|
155
|
+
print_section("📝", "Progress");
|
|
156
|
+
for t in &snapshot.todos {
|
|
157
|
+
let (icon, style) = match t.status.as_str() {
|
|
158
|
+
"completed" => ("✅", t.content.dimmed().to_string()),
|
|
159
|
+
"in_progress" => ("🔄", t.content.yellow().bold().to_string()),
|
|
160
|
+
_ => ("⏳", t.content.normal().to_string()),
|
|
161
|
+
};
|
|
162
|
+
eprintln!(" {icon} {style}");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Last error
|
|
167
|
+
if let Some(ref err) = snapshot.last_error {
|
|
168
|
+
print_section("🚨", "Last Error");
|
|
169
|
+
for line in err.lines().take(5) {
|
|
170
|
+
eprintln!(" {}", line.red());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Decisions
|
|
175
|
+
if !snapshot.decisions.is_empty() {
|
|
176
|
+
print_section("💡", "Key Decisions");
|
|
177
|
+
for d in &snapshot.decisions {
|
|
178
|
+
eprintln!(" • {}", d.dimmed());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Git
|
|
183
|
+
if let Some(ref git) = snapshot.git_state {
|
|
184
|
+
print_section("🔀", "Git State");
|
|
185
|
+
eprintln!(" Branch: {}", git.branch.green());
|
|
186
|
+
eprintln!(" {}", git.status_summary);
|
|
187
|
+
if !git.recent_commits.is_empty() {
|
|
188
|
+
eprintln!();
|
|
189
|
+
for c in git.recent_commits.iter().take(3) {
|
|
190
|
+
eprintln!(" {}", c.dimmed());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Changed files
|
|
196
|
+
if !snapshot.recent_files.is_empty() {
|
|
197
|
+
print_section("📄", &format!("Changed Files ({})", snapshot.recent_files.len()));
|
|
198
|
+
for f in snapshot.recent_files.iter().take(10) {
|
|
199
|
+
eprintln!(" {f}");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Conversation
|
|
204
|
+
if !snapshot.conversation.is_empty() {
|
|
205
|
+
print_section(
|
|
206
|
+
"💬",
|
|
207
|
+
&format!("Conversation ({} turns)", snapshot.conversation.len()),
|
|
208
|
+
);
|
|
209
|
+
let start = snapshot.conversation.len().saturating_sub(10);
|
|
210
|
+
for turn in &snapshot.conversation[start..] {
|
|
211
|
+
let (prefix, _color) = match turn.role.as_str() {
|
|
212
|
+
"user" => ("👤 YOU ", turn.content.normal().to_string()),
|
|
213
|
+
"assistant" => ("🤖 AI ", turn.content.cyan().to_string()),
|
|
214
|
+
"assistant_tool" => ("🔧 TOOL", turn.content.dimmed().to_string()),
|
|
215
|
+
"tool_result" => ("📤 OUT ", turn.content.dimmed().to_string()),
|
|
216
|
+
_ => (" ", turn.content.normal().to_string()),
|
|
217
|
+
};
|
|
218
|
+
let short = if turn.content.len() > 90 {
|
|
219
|
+
let mut end = 85;
|
|
220
|
+
while end > 0 && !turn.content.is_char_boundary(end) { end -= 1; }
|
|
221
|
+
format!("{}…", &turn.content[..end])
|
|
222
|
+
} else {
|
|
223
|
+
turn.content.clone()
|
|
224
|
+
};
|
|
225
|
+
let styled = match turn.role.as_str() {
|
|
226
|
+
"user" => short.normal().to_string(),
|
|
227
|
+
"assistant" => short.cyan().to_string(),
|
|
228
|
+
"assistant_tool" => short.dimmed().to_string(),
|
|
229
|
+
"tool_result" => short.dimmed().to_string(),
|
|
230
|
+
_ => short,
|
|
231
|
+
};
|
|
232
|
+
eprintln!(" {} {}", prefix.dimmed(), styled);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
eprintln!();
|
|
237
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Agents Display ─────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
pub fn print_agents(
|
|
243
|
+
priority: &[String],
|
|
244
|
+
statuses: &[crate::AgentStatus],
|
|
245
|
+
) {
|
|
246
|
+
eprintln!();
|
|
247
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
248
|
+
let width = term_width.min(72).max(40);
|
|
249
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
250
|
+
eprintln!(" {} {}", "🤖", "Available Agents".bold().cyan());
|
|
251
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
252
|
+
eprintln!();
|
|
253
|
+
eprintln!(
|
|
254
|
+
" Priority: {}",
|
|
255
|
+
priority
|
|
256
|
+
.iter()
|
|
257
|
+
.map(|s| s.as_str())
|
|
258
|
+
.collect::<Vec<_>>()
|
|
259
|
+
.join(" → ")
|
|
260
|
+
.dimmed()
|
|
261
|
+
);
|
|
262
|
+
eprintln!();
|
|
263
|
+
|
|
264
|
+
for s in statuses {
|
|
265
|
+
if s.available {
|
|
266
|
+
eprintln!(
|
|
267
|
+
" {} {:<12} {}",
|
|
268
|
+
"✅",
|
|
269
|
+
s.name.green().bold(),
|
|
270
|
+
s.reason.dimmed()
|
|
271
|
+
);
|
|
272
|
+
if let Some(ref v) = s.version {
|
|
273
|
+
eprintln!(" {} {:<12} {}", " ", "", format!("v{v}").dimmed());
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
eprintln!(
|
|
277
|
+
" {} {:<12} {}",
|
|
278
|
+
"❌",
|
|
279
|
+
s.name.dimmed(),
|
|
280
|
+
s.reason.dimmed()
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let available = statuses.iter().filter(|s| s.available).count();
|
|
286
|
+
eprintln!();
|
|
287
|
+
if available == 0 {
|
|
288
|
+
eprintln!(
|
|
289
|
+
" {} {}",
|
|
290
|
+
"⚠️ ",
|
|
291
|
+
"No agents available. Run 'relay init' to configure.".yellow()
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
eprintln!(
|
|
295
|
+
" {} {} agent{} ready for handoff",
|
|
296
|
+
"🚀",
|
|
297
|
+
available.to_string().green().bold(),
|
|
298
|
+
if available == 1 { "" } else { "s" }
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
eprintln!();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Handoff Result ─────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
pub fn print_handoff_success(agent: &str, file: &str) {
|
|
307
|
+
eprintln!();
|
|
308
|
+
eprintln!(
|
|
309
|
+
" {} {}",
|
|
310
|
+
"✅",
|
|
311
|
+
format!("Handed off to {agent}").green().bold()
|
|
312
|
+
);
|
|
313
|
+
eprintln!(" 📄 {}", file.dimmed());
|
|
314
|
+
eprintln!();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
pub fn print_handoff_fail(message: &str, file: &str) {
|
|
318
|
+
eprintln!();
|
|
319
|
+
eprintln!(" {} {}", "❌", message.red());
|
|
320
|
+
eprintln!();
|
|
321
|
+
eprintln!(" 💡 Context saved — copy-paste into any AI:");
|
|
322
|
+
eprintln!(" {}", file.cyan());
|
|
323
|
+
eprintln!();
|
|
324
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masyv/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off. Captures session state and hands off to Codex, Gemini, Ollama, or GPT-4.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "./scripts/build.sh",
|