@masyv/relay 0.5.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 CHANGED
@@ -1,145 +1,219 @@
1
1
  # Relay
2
2
 
3
- **When Claude's rate limit hits, another agent picks up exactly where you left off.**
3
+ **When Claude Code hits its rate limit, another agent picks up exactly where you left off — with full conversation context.**
4
4
 
5
5
  [![Rust](https://img.shields.io/badge/Rust-1.70+-orange.svg)](https://www.rust-lang.org/)
6
6
  [![npm](https://img.shields.io/npm/v/@masyv/relay)](https://www.npmjs.com/package/@masyv/relay)
7
+ [![GitHub Release](https://img.shields.io/github/v/release/Manavarya09/relay)](https://github.com/Manavarya09/relay/releases)
7
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
9
 
10
+ ![Relay — Claude to Codex handoff](relay-hero.png)
11
+
12
+ ## Features
13
+
14
+ - **Full conversation capture** — Reads Claude's actual `.jsonl` transcript, not just git
15
+ - **8 agent adapters** — Codex, Claude, Aider, Gemini, Copilot, OpenCode, Ollama, OpenAI
16
+ - **Interactive TUI** — Spinners, progress steps, fuzzy agent picker, color-coded output
17
+ - **`relay resume`** — When Claude comes back, see what the fallback agent did
18
+ - **`relay history`** — Browse all past handoffs with timestamps
19
+ - **`relay diff`** — Show exactly what changed during the handoff
20
+ - **Clipboard mode** — `--clipboard` copies handoff for pasting into any tool
21
+ - **Handoff templates** — `--template minimal|full|raw` for different formats
22
+ - **Rate limit auto-detection** — PostToolUse hook triggers handoff automatically
23
+ - **Context control** — `--turns 10 --include git,todos` to customize
24
+ - **Zero network capture** — Pure local file parsing, < 100ms
25
+ - **4.6 MB binary** — Rust, no runtime, no GC
26
+
9
27
  ## The Problem
10
28
 
11
- You're building a feature. It's 6:20 PM. You need to submit by 7 PM. Claude hits its rate limit.
29
+ It's 6:20 PM. Your submission is at 7 PM. You're deep in a Claude Code session 45 minutes of context, decisions, half-finished code. Then:
12
30
 
13
- Your entire session context — what you were building, your todos, the last error you were debugging, the architectural decisions you made — all gone. You have to re-explain everything to a new tool. By the time you're set up, it's 6:45 PM.
31
+ > **Rate limit reached. Please wait.**
14
32
 
15
- **Relay fixes this.** It captures your full session state and hands it to Codex, Gemini, Ollama, or GPT-4 automatically, with complete context so work never stops.
33
+ Your entire session context is gone. You open Codex or Gemini and spend 20 minutes re-explaining everything. By the time you're set up, it's 6:50.
16
34
 
17
- ## How It Works
35
+ ## The Solution
36
+
37
+ ```bash
38
+ relay handoff --to codex
39
+ ```
40
+
41
+ Relay reads your **actual Claude Code session** — the full conversation, every tool call, every file edit, every error — compresses it into a handoff package, and opens Codex (or Gemini, Aider, Ollama, etc.) with complete context. The new agent knows exactly what you were doing and waits for your instructions.
42
+
43
+ ## What Relay Captures
44
+
45
+ This is NOT just git state. Relay reads Claude's actual `.jsonl` session transcript:
18
46
 
19
47
  ```
20
- Claude Code session running...
21
- | (rate limit hit)
22
- v
23
- Relay captures session state:
24
- - Current task (from conversation)
25
- - Todo list + status (from TodoWrite)
26
- - Git branch, diff, recent commits
27
- - Last error / last tool output
28
- - Key decisions made
29
- - Deadline (if set)
30
- |
31
- v
32
- Relay dispatches to fallback agent:
33
- -> Codex CLI (if installed)
34
- -> Gemini (if API key set)
35
- -> Ollama (if running locally)
36
- -> GPT-4 (if API key set)
37
- |
38
- v
39
- Agent picks up EXACTLY where you left off.
48
+ ════════════════════════════════════════════════════════
49
+ 📋 Session Snapshot
50
+ ════════════════════════════════════════════════════════
51
+
52
+ 📁 /Users/dev/myproject
53
+ 🕐 2026-04-05 14:46
54
+
55
+ 🎯 Current Task
56
+ ──────────────────────────────────────────────────
57
+ Fix the mobile/desktop page separation in the footer
58
+
59
+ 📝 Progress
60
+ ──────────────────────────────────────────────────
61
+ ✅ Database schema + REST API
62
+ ✅ Landing page overhaul
63
+ 🔄 Footer link separation (IN PROGRESS)
64
+ ⏳ Auth system
65
+
66
+ 🚨 Last Error
67
+ ──────────────────────────────────────────────────
68
+ Error: Next.js couldn't find the package from project directory
69
+
70
+ 💡 Key Decisions
71
+ ──────────────────────────────────────────────────
72
+ • Using Socket.io instead of raw WebSockets
73
+ • Clean reinstall fixed the @next/swc-darwin-arm64 issue
74
+
75
+ 💬 Conversation (25 turns)
76
+ ──────────────────────────────────────────────────
77
+ 🤖 AI Now update the landing page footer too.
78
+ 🔧 TOOL [Edit] pages/index.tsx (replacing 488 chars)
79
+ 📤 OUT File updated successfully.
80
+ 🤖 AI Add /mobile to the Layout bypass list.
81
+ 🔧 TOOL [Edit] components/Layout.tsx (replacing 99 chars)
82
+ 🔧 TOOL [Bash] npx next build
83
+ 📤 OUT ✓ Build passed — 12 pages compiled
40
84
  ```
41
85
 
86
+ The fallback agent sees **everything**: what Claude was thinking, what files it edited, what errors it hit, and where it stopped.
87
+
88
+ ## 8 Supported Agents
89
+
90
+ ```
91
+ ════════════════════════════════════════════════════════
92
+ 🤖 Available Agents
93
+ ════════════════════════════════════════════════════════
94
+
95
+ Priority: codex → claude → aider → gemini → copilot → opencode → ollama → openai
96
+
97
+ ✅ codex Found at /opt/homebrew/bin/codex
98
+ ✅ copilot Found at /opt/homebrew/bin/copilot
99
+ ❌ claude Install: npm install -g @anthropic-ai/claude-code
100
+ ❌ aider Install: pip install aider-chat
101
+ ❌ gemini Set GEMINI_API_KEY env var
102
+ ❌ opencode Install: go install github.com/opencode-ai/opencode@latest
103
+ ❌ ollama Not reachable at http://localhost:11434
104
+ ❌ openai Set OPENAI_API_KEY env var
105
+
106
+ 🚀 2 agents ready for handoff
107
+ ```
108
+
109
+ | Agent | Type | How it launches |
110
+ |-------|------|-----------------|
111
+ | **Codex** | CLI (OpenAI) | Opens interactive TUI with context |
112
+ | **Claude** | CLI (Anthropic) | New Claude session with context |
113
+ | **Aider** | CLI (open source) | Opens with --message handoff |
114
+ | **Gemini** | API / CLI | Gemini CLI or REST API |
115
+ | **Copilot** | CLI (GitHub) | Opens with context |
116
+ | **OpenCode** | CLI (Go) | Opens with context |
117
+ | **Ollama** | Local API | REST call to local model |
118
+ | **OpenAI** | API | GPT-4o / GPT-5.4 API call |
119
+
42
120
  ## Quick Start
43
121
 
44
122
  ```bash
45
123
  # Install
46
124
  git clone https://github.com/Manavarya09/relay
47
- cd relay && ./scripts/build.sh && ./scripts/install.sh
125
+ cd relay && ./scripts/build.sh
126
+
127
+ # Symlink to PATH (avoids macOS quarantine)
128
+ ln -sf $(pwd)/core/target/release/relay ~/.cargo/bin/relay
48
129
 
49
130
  # Generate config
50
131
  relay init
51
132
 
52
- # Check available agents
133
+ # Check what agents you have
53
134
  relay agents
54
135
 
55
- # See what would be handed off
136
+ # See your current session snapshot
56
137
  relay status
57
138
 
58
- # Manual handoff (now)
139
+ # Hand off to Codex (interactive — opens TUI)
140
+ relay handoff --to codex
141
+
142
+ # Interactive agent picker
59
143
  relay handoff
60
144
 
61
- # Handoff to specific agent with deadline
145
+ # With deadline urgency
62
146
  relay handoff --to codex --deadline "7:00 PM"
63
147
 
64
- # Dry run just print the handoff package
65
- relay handoff --dry-run
66
- ```
148
+ # Copy to clipboard instead
149
+ relay handoff --clipboard
67
150
 
68
- ## What Relay Captures
151
+ # Minimal handoff (just task + error + git)
152
+ relay handoff --template minimal --to codex
69
153
 
154
+ # When Claude comes back — see what happened
155
+ relay resume
156
+
157
+ # List all past handoffs
158
+ relay history
159
+
160
+ # What changed since handoff?
161
+ relay diff
70
162
  ```
71
- ═══ Relay Session Snapshot ═══
72
163
 
73
- Project: /Users/dev/myproject
74
- Captured: 2026-04-05 13:32:02
164
+ ## Context Control
75
165
 
76
- ── Current Task ──
77
- Building WebSocket handler in src/server/ws.rs
166
+ ```bash
167
+ # Default: last 25 conversation turns + everything
168
+ relay handoff --to codex
78
169
 
79
- ── Todos ──
80
- [completed] Database schema + REST API
81
- 🔄 [in_progress] WebSocket handler (60% done)
82
- ⏳ [pending] Frontend charts
83
- ⏳ [pending] Auth
170
+ # Light: 10 turns only
171
+ relay handoff --to codex --turns 10
84
172
 
85
- ── Last Error ──
86
- error[E0499]: cannot borrow `state` as mutable...
173
+ # Only git state + todos (no conversation)
174
+ relay handoff --to codex --include git,todos
87
175
 
88
- ── Decisions ──
89
- Using Socket.io instead of raw WebSockets
90
- • Redis pub/sub for cross-server events
176
+ # Only conversation
177
+ relay handoff --to codex --include conversation
91
178
 
92
- ── Git ──
93
- Branch: feature/websocket
94
- 3 uncommitted changes
95
- Recent: abc1234 Add WebSocket route skeleton
179
+ # Dry run — see what gets sent without launching
180
+ relay handoff --dry-run
96
181
  ```
97
182
 
98
- ## Agent Priority
183
+ ## How It Works
184
+
185
+ 1. **Reads** `~/.claude/projects/<project>/<session>.jsonl` — Claude's actual transcript
186
+ 2. **Extracts** user messages, assistant responses, tool calls (Bash, Read, Write, Edit), tool results, errors
187
+ 3. **Reads** TodoWrite state from the JSONL (your live todo list)
188
+ 4. **Captures** git branch, diff summary, uncommitted files, recent commits
189
+ 5. **Compresses** into a handoff prompt optimized for the target agent
190
+ 6. **Launches** the agent interactively with inherited stdin/stdout
191
+
192
+ ## Config
99
193
 
100
- Configure in `~/.relay/config.toml`:
194
+ `~/.relay/config.toml`:
101
195
 
102
196
  ```toml
103
197
  [general]
104
- priority = ["codex", "gemini", "ollama", "openai"]
105
- auto_handoff = true
198
+ priority = ["codex", "claude", "aider", "gemini", "copilot", "opencode", "ollama", "openai"]
106
199
  max_context_tokens = 8000
200
+ auto_handoff = true
107
201
 
108
202
  [agents.codex]
109
- model = "o4-mini"
203
+ model = "gpt-5.4"
110
204
 
111
205
  [agents.gemini]
112
206
  api_key = "your-key"
113
- model = "gemini-2.5-pro"
114
-
115
- [agents.ollama]
116
- url = "http://localhost:11434"
117
- model = "llama3"
118
207
 
119
208
  [agents.openai]
120
209
  api_key = "your-key"
121
- model = "gpt-4o"
122
- ```
123
210
 
124
- Relay tries agents in priority order and uses the first available one.
125
-
126
- ## CLI
127
-
128
- ```
129
- COMMANDS:
130
- handoff Hand off to fallback agent (--to, --deadline, --dry-run)
131
- status Show current session snapshot
132
- agents List agents and availability
133
- init Generate default config
134
- hook PostToolUse hook (auto-detect rate limits)
135
-
136
- OPTIONS:
137
- --json Output as JSON
138
- --project Project directory (default: cwd)
139
- -v Verbose logging
211
+ [agents.ollama]
212
+ url = "http://localhost:11434"
213
+ model = "llama3"
140
214
  ```
141
215
 
142
- ## Auto-Handoff via Hook
216
+ ## Auto-Handoff (PostToolUse Hook)
143
217
 
144
218
  Add to `~/.claude/settings.json`:
145
219
 
@@ -147,23 +221,25 @@ Add to `~/.claude/settings.json`:
147
221
  {
148
222
  "hooks": {
149
223
  "PostToolUse": [
150
- {
151
- "matcher": "*",
152
- "hooks": [{ "type": "command", "command": "relay hook" }]
153
- }
224
+ { "matcher": "*", "hooks": [{ "type": "command", "command": "relay hook" }] }
154
225
  ]
155
226
  }
156
227
  }
157
228
  ```
158
229
 
159
- Relay will detect rate limit signals in tool output and automatically hand off.
230
+ Relay detects rate limit signals in tool output and automatically hands off.
160
231
 
161
232
  ## Performance
162
233
 
163
- - **4.6 MB** binary (release, stripped)
164
- - **< 100ms** to capture full session snapshot
165
- - **Zero network calls** for capture (git + file reads only)
234
+ - **4.6 MB** binary
235
+ - **< 100ms** session capture
236
+ - **Zero network calls** for capture
237
+ - **Rust** — no runtime, no GC
166
238
 
167
239
  ## License
168
240
 
169
241
  MIT
242
+
243
+ ---
244
+
245
+ Built by [@masyv](https://github.com/Manavarya09)
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "relay"
3
- version = "0.1.0"
3
+ version = "1.0.0"
4
4
  edition = "2021"
5
5
  description = "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off."
6
6
  license = "MIT"
@@ -0,0 +1,118 @@
1
+ //! `relay diff` — Show what changed since the last handoff.
2
+
3
+ use anyhow::Result;
4
+ use std::path::Path;
5
+
6
+ #[derive(Debug, serde::Serialize)]
7
+ pub struct DiffReport {
8
+ pub handoff_time: String,
9
+ pub changed_files: Vec<String>,
10
+ pub new_commits: Vec<String>,
11
+ pub diff_stat: String,
12
+ pub files_added: usize,
13
+ pub files_modified: usize,
14
+ pub files_deleted: usize,
15
+ }
16
+
17
+ /// Show what changed since the last handoff.
18
+ pub fn diff_since_handoff(project_dir: &Path) -> Result<DiffReport> {
19
+ let relay_dir = project_dir.join(".relay");
20
+ if !relay_dir.exists() {
21
+ anyhow::bail!("No .relay/ directory found. Run 'relay handoff' first.");
22
+ }
23
+
24
+ // Find latest handoff timestamp
25
+ let mut latest_time = String::new();
26
+ let mut latest_modified = std::time::SystemTime::UNIX_EPOCH;
27
+
28
+ for entry in std::fs::read_dir(&relay_dir)? {
29
+ let entry = entry?;
30
+ let name = entry.file_name().to_string_lossy().to_string();
31
+ if name.starts_with("handoff_") && name.ends_with(".md") {
32
+ if let Ok(meta) = entry.metadata() {
33
+ if let Ok(modified) = meta.modified() {
34
+ if modified > latest_modified {
35
+ latest_modified = modified;
36
+ latest_time = parse_timestamp(&name);
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ if latest_time.is_empty() {
44
+ anyhow::bail!("No handoff files found.");
45
+ }
46
+
47
+ // Git status
48
+ let status_output = run_git(project_dir, &["status", "--short"]);
49
+ let changed_files: Vec<String> = status_output
50
+ .lines()
51
+ .filter(|l| !l.is_empty())
52
+ .map(String::from)
53
+ .collect();
54
+
55
+ let mut files_added = 0;
56
+ let mut files_modified = 0;
57
+ let mut files_deleted = 0;
58
+ for f in &changed_files {
59
+ let prefix = f.trim().chars().next().unwrap_or(' ');
60
+ match prefix {
61
+ 'A' | '?' => files_added += 1,
62
+ 'M' => files_modified += 1,
63
+ 'D' => files_deleted += 1,
64
+ _ => files_modified += 1,
65
+ }
66
+ }
67
+
68
+ // Commits since handoff
69
+ let new_commits: Vec<String> = run_git(
70
+ project_dir,
71
+ &["log", "--oneline", "-10", "--since", &latest_time],
72
+ )
73
+ .lines()
74
+ .filter(|l| !l.is_empty())
75
+ .map(String::from)
76
+ .collect();
77
+
78
+ // Diff stat
79
+ let diff_stat = run_git(project_dir, &["diff", "--stat"]);
80
+
81
+ Ok(DiffReport {
82
+ handoff_time: latest_time,
83
+ changed_files,
84
+ new_commits,
85
+ diff_stat: diff_stat.trim().to_string(),
86
+ files_added,
87
+ files_modified,
88
+ files_deleted,
89
+ })
90
+ }
91
+
92
+ fn run_git(dir: &Path, args: &[&str]) -> String {
93
+ std::process::Command::new("git")
94
+ .current_dir(dir)
95
+ .args(args)
96
+ .output()
97
+ .ok()
98
+ .filter(|o| o.status.success())
99
+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
100
+ .unwrap_or_default()
101
+ }
102
+
103
+ fn parse_timestamp(filename: &str) -> String {
104
+ let ts = filename
105
+ .strip_prefix("handoff_")
106
+ .unwrap_or("")
107
+ .strip_suffix(".md")
108
+ .unwrap_or("");
109
+ if ts.len() >= 15 {
110
+ format!(
111
+ "{}-{}-{} {}:{}:{}",
112
+ &ts[0..4], &ts[4..6], &ts[6..8],
113
+ &ts[9..11], &ts[11..13], &ts[13..15]
114
+ )
115
+ } else {
116
+ ts.to_string()
117
+ }
118
+ }
@@ -1,6 +1,8 @@
1
1
  //! Handoff package builder — compresses session state into a prompt
2
2
  //! that any agent can pick up and continue from.
3
3
 
4
+ pub mod templates;
5
+
4
6
  use crate::SessionSnapshot;
5
7
  use anyhow::Result;
6
8
 
@@ -0,0 +1,74 @@
1
+ //! Handoff templates — control what context is included.
2
+
3
+ use crate::SessionSnapshot;
4
+
5
+ /// Available handoff templates.
6
+ pub enum Template {
7
+ Full, // Everything — conversation, git, todos, errors, decisions
8
+ Minimal, // Just task + error + git status
9
+ Raw, // Only the conversation turns, no formatting
10
+ }
11
+
12
+ impl Template {
13
+ pub fn from_str(s: &str) -> Self {
14
+ match s.to_lowercase().as_str() {
15
+ "minimal" | "min" => Self::Minimal,
16
+ "raw" | "conversation" => Self::Raw,
17
+ _ => Self::Full,
18
+ }
19
+ }
20
+ }
21
+
22
+ /// Build handoff using the minimal template — task + error + git only.
23
+ pub fn build_minimal(snapshot: &SessionSnapshot, target: &str) -> String {
24
+ let mut out = format!(
25
+ "══ RELAY HANDOFF (minimal) ═════════════════\n\
26
+ Agent: {target} | Project: {}\n\
27
+ ═══════════════════════════════════════════\n\n",
28
+ snapshot.project_dir
29
+ );
30
+
31
+ out.push_str(&format!("Task: {}\n\n", snapshot.current_task));
32
+
33
+ if let Some(ref err) = snapshot.last_error {
34
+ out.push_str(&format!("Error: {}\n\n", truncate(err, 300)));
35
+ }
36
+
37
+ if let Some(ref git) = snapshot.git_state {
38
+ out.push_str(&format!("Branch: {} | {}\n", git.branch, git.status_summary));
39
+ if !git.recent_commits.is_empty() {
40
+ out.push_str(&format!("Last commit: {}\n", git.recent_commits[0]));
41
+ }
42
+ }
43
+
44
+ out.push_str("\nContext restored. What would you like me to do?\n");
45
+ out
46
+ }
47
+
48
+ /// Build handoff using raw template — just conversation turns.
49
+ pub fn build_raw(snapshot: &SessionSnapshot) -> String {
50
+ let mut out = String::new();
51
+ for turn in &snapshot.conversation {
52
+ let prefix = match turn.role.as_str() {
53
+ "user" => "USER",
54
+ "assistant" => "ASSISTANT",
55
+ "assistant_tool" => "TOOL_CALL",
56
+ "tool_result" => "TOOL_OUTPUT",
57
+ _ => &turn.role,
58
+ };
59
+ out.push_str(&format!("[{prefix}] {}\n\n", turn.content));
60
+ }
61
+ if out.is_empty() {
62
+ out.push_str("(no conversation captured)\n");
63
+ }
64
+ out
65
+ }
66
+
67
+ fn truncate(s: &str, max: usize) -> String {
68
+ if s.len() <= max {
69
+ return s.to_string();
70
+ }
71
+ let mut end = max;
72
+ while end > 0 && !s.is_char_boundary(end) { end -= 1; }
73
+ format!("{}...", &s[..end])
74
+ }
@@ -0,0 +1,107 @@
1
+ //! `relay history` — List all past handoffs with metadata.
2
+
3
+ use anyhow::Result;
4
+ use std::path::Path;
5
+
6
+ #[derive(Debug, serde::Serialize)]
7
+ pub struct HandoffEntry {
8
+ pub filename: String,
9
+ pub timestamp: String,
10
+ pub agent: String,
11
+ pub task: String,
12
+ }
13
+
14
+ /// List all handoff files in .relay/ directory.
15
+ pub fn list_handoffs(project_dir: &Path, limit: usize) -> Result<Vec<HandoffEntry>> {
16
+ let relay_dir = project_dir.join(".relay");
17
+ if !relay_dir.exists() {
18
+ return Ok(Vec::new());
19
+ }
20
+
21
+ let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new();
22
+ for entry in std::fs::read_dir(&relay_dir)? {
23
+ let entry = entry?;
24
+ let name = entry.file_name().to_string_lossy().to_string();
25
+ if name.starts_with("handoff_") && name.ends_with(".md") {
26
+ if let Ok(meta) = entry.metadata() {
27
+ if let Ok(modified) = meta.modified() {
28
+ entries.push((entry.path().to_string_lossy().to_string(), modified));
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ // Sort newest first
35
+ entries.sort_by(|a, b| b.1.cmp(&a.1));
36
+ entries.truncate(limit);
37
+
38
+ let mut result = Vec::new();
39
+ for (path, _) in entries {
40
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
41
+ let filename = Path::new(&path)
42
+ .file_name()
43
+ .unwrap_or_default()
44
+ .to_string_lossy()
45
+ .to_string();
46
+
47
+ let timestamp = parse_timestamp(&filename);
48
+ let agent = extract_field(&content, "Target agent");
49
+ let task = extract_field(&content, "## CURRENT TASK")
50
+ .unwrap_or_else(|| extract_first_line_after(&content, "## CURRENT TASK"));
51
+
52
+ result.push(HandoffEntry {
53
+ filename,
54
+ timestamp,
55
+ agent: agent.unwrap_or_else(|| "unknown".into()),
56
+ task: if task.len() > 60 {
57
+ format!("{}...", &task[..57])
58
+ } else {
59
+ task
60
+ },
61
+ });
62
+ }
63
+
64
+ Ok(result)
65
+ }
66
+
67
+ fn parse_timestamp(filename: &str) -> String {
68
+ let ts = filename
69
+ .strip_prefix("handoff_")
70
+ .unwrap_or("")
71
+ .strip_suffix(".md")
72
+ .unwrap_or("");
73
+ if ts.len() >= 15 {
74
+ format!(
75
+ "{}-{}-{} {}:{}:{}",
76
+ &ts[0..4], &ts[4..6], &ts[6..8],
77
+ &ts[9..11], &ts[11..13], &ts[13..15]
78
+ )
79
+ } else {
80
+ ts.to_string()
81
+ }
82
+ }
83
+
84
+ fn extract_field(content: &str, field: &str) -> Option<String> {
85
+ for line in content.lines() {
86
+ if line.contains(field) && line.contains(':') {
87
+ let val = line.split(':').skip(1).collect::<Vec<_>>().join(":").trim().to_string();
88
+ if !val.is_empty() {
89
+ return Some(val);
90
+ }
91
+ }
92
+ }
93
+ None
94
+ }
95
+
96
+ fn extract_first_line_after(content: &str, header: &str) -> String {
97
+ if let Some(pos) = content.find(header) {
98
+ let after = &content[pos + header.len()..];
99
+ for line in after.lines() {
100
+ let trimmed = line.trim();
101
+ if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("──") {
102
+ return trimmed.to_string();
103
+ }
104
+ }
105
+ }
106
+ "unknown".into()
107
+ }
package/core/src/lib.rs CHANGED
@@ -1,7 +1,10 @@
1
1
  pub mod agents;
2
2
  pub mod capture;
3
3
  pub mod detect;
4
+ pub mod diff;
4
5
  pub mod handoff;
6
+ pub mod history;
7
+ pub mod resume;
5
8
  pub mod tui;
6
9
 
7
10
  use serde::{Deserialize, Serialize};
package/core/src/main.rs CHANGED
@@ -50,6 +50,14 @@ enum Commands {
50
50
  /// What to include: all, conversation, git, todos (comma-separated)
51
51
  #[arg(long, default_value = "all")]
52
52
  include: String,
53
+
54
+ /// Copy handoff to clipboard instead of launching agent
55
+ #[arg(long)]
56
+ clipboard: bool,
57
+
58
+ /// Handoff template: full (default), minimal, raw
59
+ #[arg(long, default_value = "full")]
60
+ template: String,
53
61
  },
54
62
 
55
63
  /// Show current session snapshot
@@ -58,6 +66,19 @@ enum Commands {
58
66
  /// List configured agents and availability
59
67
  Agents,
60
68
 
69
+ /// Resume after rate limit resets — show what happened during handoff
70
+ Resume,
71
+
72
+ /// List past handoffs
73
+ History {
74
+ /// Number of entries to show
75
+ #[arg(long, default_value = "10")]
76
+ limit: usize,
77
+ },
78
+
79
+ /// Show what changed since the last handoff
80
+ Diff,
81
+
61
82
  /// Generate default config at ~/.relay/config.toml
62
83
  Init,
63
84
 
@@ -92,7 +113,7 @@ fn main() -> Result<()> {
92
113
  // ═══════════════════════════════════════════════════════════════
93
114
  // HANDOFF
94
115
  // ═══════════════════════════════════════════════════════════════
95
- Commands::Handoff { to, deadline, dry_run, turns, include } => {
116
+ Commands::Handoff { to, deadline, dry_run, turns, include, clipboard, template } => {
96
117
  if !cli.json {
97
118
  tui::print_banner();
98
119
  }
@@ -142,9 +163,18 @@ fn main() -> Result<()> {
142
163
  "auto".into()
143
164
  };
144
165
 
145
- let handoff_text = handoff::build_handoff(
146
- &snapshot, &target_name, config.general.max_context_tokens,
147
- )?;
166
+ // Build handoff using selected template
167
+ let handoff_text = match handoff::templates::Template::from_str(&template) {
168
+ handoff::templates::Template::Minimal => {
169
+ handoff::templates::build_minimal(&snapshot, &target_name)
170
+ }
171
+ handoff::templates::Template::Raw => {
172
+ handoff::templates::build_raw(&snapshot)
173
+ }
174
+ handoff::templates::Template::Full => {
175
+ handoff::build_handoff(&snapshot, &target_name, config.general.max_context_tokens)?
176
+ }
177
+ };
148
178
  let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
149
179
 
150
180
  if let Some(sp) = sp { sp.finish_with_message("Handoff built"); }
@@ -159,6 +189,29 @@ fn main() -> Result<()> {
159
189
  }))?);
160
190
  return Ok(());
161
191
  }
192
+ // Clipboard mode
193
+ if clipboard {
194
+ #[cfg(target_os = "macos")]
195
+ {
196
+ use std::process::{Command, Stdio};
197
+ let mut child = Command::new("pbcopy")
198
+ .stdin(Stdio::piped())
199
+ .spawn()?;
200
+ if let Some(mut stdin) = child.stdin.take() {
201
+ use std::io::Write;
202
+ stdin.write_all(handoff_text.as_bytes())?;
203
+ }
204
+ child.wait()?;
205
+ eprintln!(" 📋 Handoff copied to clipboard!");
206
+ eprintln!(" 📄 Also saved: {}", handoff_path.display());
207
+ }
208
+ #[cfg(not(target_os = "macos"))]
209
+ {
210
+ eprintln!(" Clipboard not supported on this platform.");
211
+ eprintln!(" 📄 Saved to: {}", handoff_path.display());
212
+ }
213
+ return Ok(());
214
+ }
162
215
  if dry_run {
163
216
  println!("{handoff_text}");
164
217
  eprintln!();
@@ -218,6 +271,108 @@ fn main() -> Result<()> {
218
271
  }
219
272
  }
220
273
 
274
+ // ═══════════════════════════════════════════════════════════════
275
+ // RESUME
276
+ // ═══════════════════════════════════════════════════════════════
277
+ Commands::Resume => {
278
+ let report = relay::resume::build_resume(&project_dir)?;
279
+
280
+ if cli.json {
281
+ println!("{}", serde_json::to_string_pretty(&report)?);
282
+ } else {
283
+ tui::print_section("🔄", "Resume — What Happened During Handoff");
284
+ eprintln!(" Handoff at: {}", report.handoff_time);
285
+ eprintln!(" Task: {}", report.original_task);
286
+ eprintln!();
287
+
288
+ if !report.new_commits.is_empty() {
289
+ tui::print_section("📝", &format!("New Commits ({})", report.new_commits.len()));
290
+ for c in &report.new_commits {
291
+ eprintln!(" {c}");
292
+ }
293
+ }
294
+
295
+ if !report.changes_since.is_empty() {
296
+ tui::print_section("📄", &format!("Changed Files ({})", report.changes_since.len()));
297
+ for f in &report.changes_since {
298
+ eprintln!(" {f}");
299
+ }
300
+ }
301
+
302
+ if !report.diff_stat.is_empty() {
303
+ tui::print_section("📊", "Diff Summary");
304
+ for line in report.diff_stat.lines() {
305
+ eprintln!(" {line}");
306
+ }
307
+ }
308
+
309
+ eprintln!();
310
+ eprintln!(" 📋 Resume prompt ready. Use --json to get the full prompt.");
311
+ }
312
+ }
313
+
314
+ // ═══════════════════════════════════════════════════════════════
315
+ // HISTORY
316
+ // ═══════════════════════════════════════════════════════════════
317
+ Commands::History { limit } => {
318
+ let entries = relay::history::list_handoffs(&project_dir, limit)?;
319
+
320
+ if cli.json {
321
+ println!("{}", serde_json::to_string_pretty(&entries)?);
322
+ return Ok(());
323
+ }
324
+
325
+ if entries.is_empty() {
326
+ eprintln!(" No handoffs recorded yet.");
327
+ return Ok(());
328
+ }
329
+
330
+ tui::print_section("📜", &format!("Handoff History ({} entries)", entries.len()));
331
+ eprintln!();
332
+ for e in &entries {
333
+ eprintln!(
334
+ " {} → {:<10} {}",
335
+ e.timestamp, e.agent, e.task
336
+ );
337
+ }
338
+ eprintln!();
339
+ }
340
+
341
+ // ═══════════════════════════════════════════════════════════════
342
+ // DIFF
343
+ // ═══════════════════════════════════════════════════════════════
344
+ Commands::Diff => {
345
+ let report = relay::diff::diff_since_handoff(&project_dir)?;
346
+
347
+ if cli.json {
348
+ println!("{}", serde_json::to_string_pretty(&report)?);
349
+ return Ok(());
350
+ }
351
+
352
+ tui::print_section("📊", "Changes Since Last Handoff");
353
+ eprintln!(" Handoff at: {}", report.handoff_time);
354
+ eprintln!(
355
+ " {} added, {} modified, {} deleted",
356
+ report.files_added, report.files_modified, report.files_deleted
357
+ );
358
+
359
+ if !report.new_commits.is_empty() {
360
+ eprintln!();
361
+ eprintln!(" Commits:");
362
+ for c in &report.new_commits {
363
+ eprintln!(" {c}");
364
+ }
365
+ }
366
+
367
+ if !report.diff_stat.is_empty() {
368
+ eprintln!();
369
+ for line in report.diff_stat.lines() {
370
+ eprintln!(" {line}");
371
+ }
372
+ }
373
+ eprintln!();
374
+ }
375
+
221
376
  // ═══════════════════════════════════════════════════════════════
222
377
  // INIT
223
378
  // ═══════════════════════════════════════════════════════════════
@@ -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 CHANGED
@@ -208,7 +208,7 @@ pub fn print_snapshot(snapshot: &crate::SessionSnapshot) {
208
208
  );
209
209
  let start = snapshot.conversation.len().saturating_sub(10);
210
210
  for turn in &snapshot.conversation[start..] {
211
- let (prefix, color) = match turn.role.as_str() {
211
+ let (prefix, _color) = match turn.role.as_str() {
212
212
  "user" => ("👤 YOU ", turn.content.normal().to_string()),
213
213
  "assistant" => ("🤖 AI ", turn.content.cyan().to_string()),
214
214
  "assistant_tool" => ("🔧 TOOL", turn.content.dimmed().to_string()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masyv/relay",
3
- "version": "0.5.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",