@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 +166 -90
- package/core/Cargo.toml +1 -1
- 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 +3 -0
- package/core/src/main.rs +159 -4
- package/core/src/resume.rs +202 -0
- package/core/src/tui.rs +1 -1
- 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
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
|
+
}
|
package/core/src/lib.rs
CHANGED
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
|
-
|
|
146
|
-
|
|
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,
|
|
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.
|
|
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",
|