@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
package/README.md
CHANGED
|
@@ -1,145 +1,219 @@
|
|
|
1
1
|
# Relay
|
|
2
2
|
|
|
3
|
-
**When Claude
|
|
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
|
[](https://www.rust-lang.org/)
|
|
6
6
|
[](https://www.npmjs.com/package/@masyv/relay)
|
|
7
|
+
[](https://github.com/Manavarya09/relay/releases)
|
|
7
8
|
[](LICENSE)
|
|
8
9
|
|
|
10
|
+

|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
> **Rate limit reached. Please wait.**
|
|
14
32
|
|
|
15
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
133
|
+
# Check what agents you have
|
|
53
134
|
relay agents
|
|
54
135
|
|
|
55
|
-
# See
|
|
136
|
+
# See your current session snapshot
|
|
56
137
|
relay status
|
|
57
138
|
|
|
58
|
-
#
|
|
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
|
-
#
|
|
145
|
+
# With deadline urgency
|
|
62
146
|
relay handoff --to codex --deadline "7:00 PM"
|
|
63
147
|
|
|
64
|
-
#
|
|
65
|
-
relay handoff --
|
|
66
|
-
```
|
|
148
|
+
# Copy to clipboard instead
|
|
149
|
+
relay handoff --clipboard
|
|
67
150
|
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
Captured: 2026-04-05 13:32:02
|
|
164
|
+
## Context Control
|
|
75
165
|
|
|
76
|
-
|
|
77
|
-
|
|
166
|
+
```bash
|
|
167
|
+
# Default: last 25 conversation turns + everything
|
|
168
|
+
relay handoff --to codex
|
|
78
169
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
173
|
+
# Only git state + todos (no conversation)
|
|
174
|
+
relay handoff --to codex --include git,todos
|
|
87
175
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
• Redis pub/sub for cross-server events
|
|
176
|
+
# Only conversation
|
|
177
|
+
relay handoff --to codex --include conversation
|
|
91
178
|
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
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
|
|
164
|
-
- **< 100ms**
|
|
165
|
-
- **Zero network calls** for capture
|
|
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 = "
|
|
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"
|
|
@@ -42,8 +42,11 @@ blake3 = "1"
|
|
|
42
42
|
# HTTP client (for Ollama, OpenAI, Gemini APIs)
|
|
43
43
|
ureq = { version = "2", features = ["json"] }
|
|
44
44
|
|
|
45
|
-
# Terminal
|
|
45
|
+
# Terminal UI
|
|
46
46
|
colored = "2"
|
|
47
|
+
indicatif = "0.17"
|
|
48
|
+
dialoguer = { version = "0.11", features = ["fuzzy-select"] }
|
|
49
|
+
console = "0.15"
|
|
47
50
|
|
|
48
51
|
[profile.release]
|
|
49
52
|
opt-level = 3
|
package/core/src/diff.rs
ADDED
|
@@ -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
|
+
}
|
package/core/src/handoff/mod.rs
CHANGED
|
@@ -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
|
+
}
|