@masyv/relay 0.1.1 → 0.3.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.
@@ -69,33 +69,27 @@ impl Agent for CodexAgent {
69
69
  let tmp = std::env::temp_dir().join("relay_handoff.md");
70
70
  std::fs::write(&tmp, handoff_prompt)?;
71
71
 
72
- // Use `codex exec --full-auto` for non-interactive auto-approval
73
- let output = Command::new(&binary)
72
+ // Launch Codex INTERACTIVELY with the handoff as the initial prompt.
73
+ // This opens the Codex TUI so the user can keep working with it.
74
+ // stdin/stdout/stderr are inherited so the user sees the Codex UI.
75
+ let status = Command::new(&binary)
74
76
  .current_dir(project_dir)
75
- .arg("exec")
76
77
  .arg("--full-auto")
77
78
  .arg("-m")
78
79
  .arg(&self.model)
79
80
  .arg(handoff_prompt)
80
- .output()?;
81
-
82
- let stdout = String::from_utf8_lossy(&output.stdout);
83
- let stderr = String::from_utf8_lossy(&output.stderr);
84
-
85
- if !stdout.is_empty() {
86
- println!("{stdout}");
87
- }
88
- if !stderr.is_empty() {
89
- eprintln!("{stderr}");
90
- }
81
+ .stdin(std::process::Stdio::inherit())
82
+ .stdout(std::process::Stdio::inherit())
83
+ .stderr(std::process::Stdio::inherit())
84
+ .status()?;
91
85
 
92
86
  Ok(HandoffResult {
93
87
  agent: "codex".into(),
94
- success: output.status.success(),
95
- message: if output.status.success() {
96
- format!("Codex ({}) completed handoff task", self.model)
88
+ success: status.success(),
89
+ message: if status.success() {
90
+ format!("Codex ({}) session ended", self.model)
97
91
  } else {
98
- format!("Codex exited with code {:?}", output.status.code())
92
+ format!("Codex exited with code {:?}", status.code())
99
93
  },
100
94
  handoff_file: Some(tmp.to_string_lossy().to_string()),
101
95
  })
@@ -26,6 +26,15 @@ pub fn capture_snapshot(
26
26
 
27
27
  let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
28
28
 
29
+ // Convert conversation turns
30
+ let conversation: Vec<crate::ConversationTurn> = session_info.conversation
31
+ .into_iter()
32
+ .map(|t| crate::ConversationTurn {
33
+ role: t.role,
34
+ content: t.content,
35
+ })
36
+ .collect();
37
+
29
38
  Ok(SessionSnapshot {
30
39
  current_task: session_info.current_task,
31
40
  todos,
@@ -37,5 +46,6 @@ pub fn capture_snapshot(
37
46
  recent_files,
38
47
  timestamp,
39
48
  deadline: deadline.map(String::from),
49
+ conversation,
40
50
  })
41
51
  }
@@ -1,6 +1,12 @@
1
1
  //! Read Claude Code session state from .jsonl transcript files.
2
+ //! Extracts the FULL conversation context — user messages, assistant reasoning,
3
+ //! tool calls with results, errors, and decisions.
2
4
 
3
5
  use std::path::Path;
6
+ use std::sync::atomic::AtomicUsize;
7
+
8
+ /// Controls how many conversation turns to keep. Set before calling read_latest_session.
9
+ pub static MAX_CONVERSATION_TURNS: AtomicUsize = AtomicUsize::new(25);
4
10
 
5
11
  /// Extracted session info from Claude's transcript.
6
12
  pub struct SessionInfo {
@@ -8,13 +14,19 @@ pub struct SessionInfo {
8
14
  pub decisions: Vec<String>,
9
15
  pub last_error: Option<String>,
10
16
  pub last_output: Option<String>,
17
+ /// Full conversation turns (compressed) — the real context
18
+ pub conversation: Vec<ConversationTurn>,
11
19
  }
12
20
 
13
- /// Read the latest Claude session transcript and extract state.
14
- pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
15
- // Claude stores transcripts in ~/.claude/projects/<project_hash>/<session>.jsonl
16
- // Try to find and parse the latest one
21
+ /// A single turn in the conversation.
22
+ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23
+ pub struct ConversationTurn {
24
+ pub role: String, // "user", "assistant", "tool_result"
25
+ pub content: String,
26
+ }
17
27
 
28
+ /// Read the latest Claude session transcript and extract full context.
29
+ pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
18
30
  let claude_dir = find_claude_project_dir(project_dir);
19
31
 
20
32
  if let Some(dir) = claude_dir {
@@ -29,17 +41,18 @@ pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
29
41
  decisions: Vec::new(),
30
42
  last_error: None,
31
43
  last_output: None,
44
+ conversation: Vec::new(),
32
45
  }
33
46
  }
34
47
 
35
- fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
48
+ pub fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
36
49
  let home = std::env::var("HOME").ok()?;
37
50
  let claude_projects = std::path::PathBuf::from(&home).join(".claude/projects");
38
51
  if !claude_projects.exists() {
39
52
  return None;
40
53
  }
41
54
 
42
- // Claude encodes the project path: /Users/user/myproject -> -Users-user-myproject
55
+ // Claude encodes: /Users/user/myproject -> -Users-user-myproject
43
56
  let proj_str = project_dir.to_string_lossy().replace('/', "-");
44
57
  let candidate = claude_projects.join(&proj_str);
45
58
  if candidate.exists() {
@@ -48,10 +61,10 @@ fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
48
61
 
49
62
  // Try matching by suffix
50
63
  if let Ok(entries) = std::fs::read_dir(&claude_projects) {
51
- let dir_name = project_dir.file_name()?.to_string_lossy();
64
+ let dir_name = project_dir.file_name()?.to_string_lossy().to_string();
52
65
  for entry in entries.flatten() {
53
66
  let name = entry.file_name().to_string_lossy().to_string();
54
- if name.ends_with(&*dir_name) && entry.path().is_dir() {
67
+ if name.ends_with(&dir_name) && entry.path().is_dir() {
55
68
  return Some(entry.path());
56
69
  }
57
70
  }
@@ -85,82 +98,131 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
85
98
  Err(_) => return default_session_info(),
86
99
  };
87
100
 
101
+ let lines: Vec<&str> = content.lines().collect();
102
+
88
103
  let mut current_task = String::new();
89
104
  let mut decisions = Vec::new();
90
105
  let mut last_error = None;
91
106
  let mut last_output = None;
107
+ let mut conversation: Vec<ConversationTurn> = Vec::new();
92
108
 
93
- // Read last 200 lines of the JSONL for efficiency
94
- let lines: Vec<&str> = content.lines().collect();
95
- let start = lines.len().saturating_sub(200);
96
-
97
- for line in &lines[start..] {
109
+ for line in &lines {
98
110
  let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
111
+ let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
112
+ let message = val.get("message").cloned().unwrap_or_default();
113
+ let msg_content = message.get("content");
99
114
 
100
- // Extract user messages for task context
101
- if val.get("type").and_then(|v| v.as_str()) == Some("human") {
102
- if let Some(msg) = val.get("message").and_then(|m| {
103
- m.get("content").and_then(|c| {
104
- if let Some(s) = c.as_str() {
105
- Some(s.to_string())
106
- } else if let Some(arr) = c.as_array() {
107
- arr.iter()
108
- .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
109
- .last()
110
- .map(String::from)
111
- } else {
112
- None
113
- }
114
- })
115
- }) {
116
- // Keep the last substantive user message as current task
117
- if msg.len() > 10 && !msg.starts_with('/') {
118
- current_task = if msg.len() > 500 {
119
- format!("{}...", &msg[..500])
120
- } else {
121
- msg
122
- };
123
- }
124
- }
125
- }
115
+ match msg_type {
116
+ // ── User messages ──────────────────────────────────────────
117
+ "user" => {
118
+ // Check if this is a tool result
119
+ if let Some(tool_result) = val.get("toolUseResult") {
120
+ // Tool result content is in message.content[].content
121
+ if let Some(items) = msg_content.and_then(|c| c.as_array()) {
122
+ for item in items {
123
+ if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
124
+ let result_text = item.get("content")
125
+ .and_then(|c| {
126
+ if let Some(s) = c.as_str() {
127
+ Some(s.to_string())
128
+ } else if let Some(arr) = c.as_array() {
129
+ Some(arr.iter()
130
+ .filter_map(|i| i.get("text").and_then(|t| t.as_str()))
131
+ .collect::<Vec<_>>()
132
+ .join("\n"))
133
+ } else {
134
+ None
135
+ }
136
+ })
137
+ .unwrap_or_default();
138
+
139
+ if !result_text.is_empty() {
140
+ // Check for errors
141
+ let lower = result_text.to_lowercase();
142
+ if lower.contains("error") || lower.contains("failed") ||
143
+ lower.contains("panic") || lower.contains("exit code") {
144
+ last_error = Some(truncate(&result_text, 800));
145
+ }
146
+ last_output = Some(truncate(&result_text, 800));
126
147
 
127
- // Extract assistant tool use for last output
128
- if val.get("type").and_then(|v| v.as_str()) == Some("assistant") {
129
- if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
130
- if let Some(arr) = content.as_array() {
131
- for item in arr {
132
- // Look for tool results
133
- if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
134
- if let Some(output) = item.get("content").and_then(|c| c.as_str()) {
135
- // Check for errors
136
- let lower = output.to_lowercase();
137
- if lower.contains("error") || lower.contains("failed") || lower.contains("panic") {
138
- last_error = Some(truncate(output, 500));
148
+ conversation.push(ConversationTurn {
149
+ role: "tool_result".into(),
150
+ content: truncate(&result_text, 200),
151
+ });
139
152
  }
140
- last_output = Some(truncate(output, 500));
141
153
  }
142
154
  }
143
- // Look for text content with decisions
144
- if item.get("type").and_then(|t| t.as_str()) == Some("text") {
145
- if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
146
- // Extract lines that look like decisions
147
- for line in text.lines() {
148
- let trimmed = line.trim();
149
- if (trimmed.starts_with("I'll use") ||
150
- trimmed.starts_with("I chose") ||
151
- trimmed.starts_with("Decision:") ||
152
- trimmed.starts_with("Using ") ||
153
- trimmed.starts_with("Approach:"))
154
- && trimmed.len() > 15
155
- {
156
- decisions.push(truncate(trimmed, 200));
155
+ }
156
+ // Also check stdout/stderr from toolUseResult
157
+ let stdout = tool_result.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
158
+ let stderr = tool_result.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
159
+ if !stdout.is_empty() {
160
+ last_output = Some(truncate(stdout, 800));
161
+ }
162
+ if !stderr.is_empty() {
163
+ last_error = Some(truncate(stderr, 800));
164
+ }
165
+ } else {
166
+ // Regular user message
167
+ let user_text = extract_text_content(msg_content);
168
+ if !user_text.is_empty() && user_text.len() > 3 {
169
+ // Update current task from the last substantive user message
170
+ if user_text.len() > 10 && !user_text.starts_with('/') {
171
+ current_task = truncate(&user_text, 500);
172
+ }
173
+ conversation.push(ConversationTurn {
174
+ role: "user".into(),
175
+ content: truncate(&user_text, 300),
176
+ });
177
+ }
178
+ }
179
+ }
180
+
181
+ // ── Assistant messages ──────────────────────────────────────
182
+ "assistant" => {
183
+ if let Some(items) = msg_content.and_then(|c| c.as_array()) {
184
+ for item in items {
185
+ let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
186
+ match item_type {
187
+ "text" => {
188
+ let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
189
+ if !text.is_empty() {
190
+ conversation.push(ConversationTurn {
191
+ role: "assistant".into(),
192
+ content: truncate(text, 200),
193
+ });
194
+ // Extract decisions
195
+ for line in text.lines() {
196
+ let trimmed = line.trim();
197
+ if (trimmed.starts_with("I'll ") ||
198
+ trimmed.starts_with("I chose") ||
199
+ trimmed.starts_with("Using ") ||
200
+ trimmed.starts_with("Decision:") ||
201
+ trimmed.starts_with("Approach:") ||
202
+ trimmed.starts_with("The fix is") ||
203
+ trimmed.starts_with("The issue"))
204
+ && trimmed.len() > 20
205
+ {
206
+ decisions.push(truncate(trimmed, 200));
207
+ }
157
208
  }
158
209
  }
159
210
  }
211
+ "tool_use" => {
212
+ let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?");
213
+ let input = item.get("input").cloned().unwrap_or_default();
214
+ let summary = summarize_tool_call(name, &input);
215
+ conversation.push(ConversationTurn {
216
+ role: "assistant_tool".into(),
217
+ content: summary,
218
+ });
219
+ }
220
+ _ => {}
160
221
  }
161
222
  }
162
223
  }
163
224
  }
225
+ _ => {} // skip system, queue-operation, etc.
164
226
  }
165
227
  }
166
228
 
@@ -168,20 +230,90 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
168
230
  current_task = "Could not determine current task from session transcript".into();
169
231
  }
170
232
 
171
- // Deduplicate and limit decisions
233
+ // Deduplicate decisions
172
234
  decisions.dedup();
173
- decisions.truncate(10);
235
+ decisions.truncate(15);
236
+
237
+ // Keep last N conversation turns — caller can override via max_conversation_turns
238
+ let max_turns = MAX_CONVERSATION_TURNS.load(std::sync::atomic::Ordering::Relaxed);
239
+ if conversation.len() > max_turns {
240
+ let skip = conversation.len() - max_turns;
241
+ conversation = conversation.into_iter().skip(skip).collect();
242
+ }
174
243
 
175
244
  SessionInfo {
176
245
  current_task,
177
246
  decisions,
178
247
  last_error,
179
248
  last_output,
249
+ conversation,
180
250
  }
181
251
  }
182
252
 
253
+ /// Summarize a tool call into a human-readable line.
254
+ fn summarize_tool_call(name: &str, input: &serde_json::Value) -> String {
255
+ match name {
256
+ "Write" => {
257
+ let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
258
+ let content_len = input.get("content").and_then(|c| c.as_str()).map(|s| s.len()).unwrap_or(0);
259
+ format!("[Write] {} ({} chars)", path, content_len)
260
+ }
261
+ "Edit" => {
262
+ let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
263
+ let old = input.get("old_string").and_then(|o| o.as_str()).unwrap_or("");
264
+ format!("[Edit] {} (replacing {} chars)", path, old.len())
265
+ }
266
+ "Read" => {
267
+ let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
268
+ format!("[Read] {}", path)
269
+ }
270
+ "Bash" => {
271
+ let cmd = input.get("command").and_then(|c| c.as_str()).unwrap_or("?");
272
+ format!("[Bash] {}", truncate(cmd, 120))
273
+ }
274
+ "Glob" => {
275
+ let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
276
+ format!("[Glob] {}", pat)
277
+ }
278
+ "Grep" => {
279
+ let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
280
+ format!("[Grep] {}", pat)
281
+ }
282
+ "TodoWrite" => {
283
+ let todos = input.get("todos").and_then(|t| t.as_array());
284
+ let count = todos.map(|t| t.len()).unwrap_or(0);
285
+ format!("[TodoWrite] {} items", count)
286
+ }
287
+ "Agent" => {
288
+ let desc = input.get("description").and_then(|d| d.as_str()).unwrap_or("?");
289
+ format!("[Agent] {}", desc)
290
+ }
291
+ _ => format!("[{}]", name),
292
+ }
293
+ }
294
+
295
+ /// Extract text content from a message content field.
296
+ fn extract_text_content(content: Option<&serde_json::Value>) -> String {
297
+ let Some(c) = content else { return String::new() };
298
+ if let Some(s) = c.as_str() {
299
+ return s.to_string();
300
+ }
301
+ if let Some(arr) = c.as_array() {
302
+ let texts: Vec<&str> = arr.iter()
303
+ .filter_map(|item| {
304
+ if item.get("type").and_then(|t| t.as_str()) == Some("text") {
305
+ item.get("text").and_then(|t| t.as_str())
306
+ } else {
307
+ None
308
+ }
309
+ })
310
+ .collect();
311
+ return texts.join("\n");
312
+ }
313
+ String::new()
314
+ }
315
+
183
316
  fn infer_task_from_git(project_dir: &Path) -> String {
184
- // Infer from recent commit messages
185
317
  let output = std::process::Command::new("git")
186
318
  .current_dir(project_dir)
187
319
  .args(["log", "--oneline", "-1", "--no-decorate"])
@@ -201,10 +333,14 @@ fn infer_task_from_git(project_dir: &Path) -> String {
201
333
 
202
334
  fn truncate(s: &str, max: usize) -> String {
203
335
  if s.len() <= max {
204
- s.to_string()
205
- } else {
206
- format!("{}...", &s[..max])
336
+ return s.to_string();
337
+ }
338
+ // Find a valid char boundary at or before max
339
+ let mut end = max;
340
+ while end > 0 && !s.is_char_boundary(end) {
341
+ end -= 1;
207
342
  }
343
+ format!("{}...", &s[..end])
208
344
  }
209
345
 
210
346
  fn default_session_info() -> SessionInfo {
@@ -213,5 +349,6 @@ fn default_session_info() -> SessionInfo {
213
349
  decisions: Vec::new(),
214
350
  last_error: None,
215
351
  last_output: None,
352
+ conversation: Vec::new(),
216
353
  }
217
354
  }
@@ -100,6 +100,22 @@ pub fn build_handoff(
100
100
  sections.push(files);
101
101
  }
102
102
 
103
+ // ── Full Conversation Context ──────────────────────────────
104
+ if !snapshot.conversation.is_empty() {
105
+ let mut convo = String::from("## CONVERSATION CONTEXT\n\nBelow is the full conversation from the Claude session (most recent turns).\nThis is the actual context that was in Claude's window when it was interrupted.\n\n");
106
+ for turn in &snapshot.conversation {
107
+ let prefix = match turn.role.as_str() {
108
+ "user" => "USER",
109
+ "assistant" => "CLAUDE",
110
+ "assistant_tool" => "CLAUDE_TOOL",
111
+ "tool_result" => "TOOL_OUTPUT",
112
+ _ => &turn.role,
113
+ };
114
+ convo.push_str(&format!("[{prefix}] {}\n\n", turn.content));
115
+ }
116
+ sections.push(convo);
117
+ }
118
+
103
119
  // ── Instructions for agent ─────────────────────────────────
104
120
  let instructions = format!(
105
121
  "## INSTRUCTIONS\n\n\
@@ -119,11 +135,15 @@ pub fn build_handoff(
119
135
 
120
136
  let mut full = sections.join("\n\n");
121
137
 
122
- // Truncate if too long (rough token estimate: chars / 3.5)
123
- let estimated_tokens = full.len() as f64 / 3.5;
124
- if estimated_tokens > max_tokens as f64 {
125
- let max_chars = (max_tokens as f64 * 3.5) as usize;
126
- full.truncate(max_chars);
138
+ // Hard cap at max_tokens (rough estimate: chars / 3.5)
139
+ let max_chars = (max_tokens as f64 * 3.5) as usize;
140
+ if full.len() > max_chars {
141
+ // Find a valid UTF-8 char boundary
142
+ let mut end = max_chars;
143
+ while end > 0 && !full.is_char_boundary(end) {
144
+ end -= 1;
145
+ }
146
+ full.truncate(end);
127
147
  full.push_str("\n\n[...truncated to fit context limit]");
128
148
  }
129
149
 
package/core/src/lib.rs CHANGED
@@ -162,6 +162,15 @@ pub struct SessionSnapshot {
162
162
  pub timestamp: String,
163
163
  /// Deadline if set
164
164
  pub deadline: Option<String>,
165
+ /// FULL conversation context from Claude session transcript
166
+ pub conversation: Vec<ConversationTurn>,
167
+ }
168
+
169
+ /// A single turn in the conversation (user message, assistant text, or tool call/result).
170
+ #[derive(Debug, Clone, Serialize, Deserialize)]
171
+ pub struct ConversationTurn {
172
+ pub role: String,
173
+ pub content: String,
165
174
  }
166
175
 
167
176
  #[derive(Debug, Clone, Serialize, Deserialize)]
package/core/src/main.rs CHANGED
@@ -44,6 +44,14 @@ enum Commands {
44
44
  /// Don't execute — just print the handoff package
45
45
  #[arg(long)]
46
46
  dry_run: bool,
47
+
48
+ /// How many conversation turns to include (default: 25)
49
+ #[arg(long, default_value = "25")]
50
+ turns: usize,
51
+
52
+ /// What to include: all, conversation, git, todos (comma-separated)
53
+ #[arg(long, default_value = "all")]
54
+ include: String,
47
55
  },
48
56
 
49
57
  /// Show current session snapshot (what would be handed off)
@@ -84,14 +92,33 @@ fn main() -> Result<()> {
84
92
  });
85
93
 
86
94
  match cli.command {
87
- Commands::Handoff { to, deadline, dry_run } => {
95
+ Commands::Handoff { to, deadline, dry_run, turns, include } => {
88
96
  eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
89
97
 
90
- let snapshot = capture::capture_snapshot(
98
+ // Set conversation turn limit before capture
99
+ relay::capture::session::MAX_CONVERSATION_TURNS
100
+ .store(turns, std::sync::atomic::Ordering::Relaxed);
101
+
102
+ let mut snapshot = capture::capture_snapshot(
91
103
  &project_dir,
92
104
  deadline.as_deref(),
93
105
  )?;
94
106
 
107
+ // Filter sections based on --include flag
108
+ let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
109
+ if !includes.contains(&"all") {
110
+ if !includes.contains(&"conversation") {
111
+ snapshot.conversation.clear();
112
+ }
113
+ if !includes.contains(&"git") {
114
+ snapshot.git_state = None;
115
+ snapshot.recent_files.clear();
116
+ }
117
+ if !includes.contains(&"todos") {
118
+ snapshot.todos.clear();
119
+ }
120
+ }
121
+
95
122
  let target = to.as_deref().unwrap_or("auto");
96
123
  let handoff_text = handoff::build_handoff(
97
124
  &snapshot,
@@ -206,6 +233,28 @@ fn main() -> Result<()> {
206
233
  }
207
234
  println!();
208
235
  }
236
+
237
+ if !snapshot.conversation.is_empty() {
238
+ println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
239
+ // Show last 15 turns
240
+ let start = snapshot.conversation.len().saturating_sub(15);
241
+ for turn in &snapshot.conversation[start..] {
242
+ let prefix = match turn.role.as_str() {
243
+ "user" => "👤 USER".to_string(),
244
+ "assistant" => "🤖 CLAUDE".to_string(),
245
+ "assistant_tool" => "🔧 TOOL".to_string(),
246
+ "tool_result" => "📤 RESULT".to_string(),
247
+ _ => turn.role.clone(),
248
+ };
249
+ let content = if turn.content.len() > 120 {
250
+ format!("{}...", &turn.content[..117])
251
+ } else {
252
+ turn.content.clone()
253
+ };
254
+ println!(" {}: {}", prefix, content);
255
+ }
256
+ println!();
257
+ }
209
258
  }
210
259
 
211
260
  Commands::Agents => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masyv/relay",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",