@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.
@@ -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
+ }
@@ -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.4.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",