handoff-mcp-server 0.16.0 → 0.17.1

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/Cargo.lock CHANGED
@@ -136,7 +136,7 @@ dependencies = [
136
136
 
137
137
  [[package]]
138
138
  name = "handoff-mcp"
139
- version = "0.16.0"
139
+ version = "0.17.1"
140
140
  dependencies = [
141
141
  "anyhow",
142
142
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.16.0"
3
+ version = "0.17.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -177,6 +177,8 @@ manual configuration alternative.
177
177
  | `handoff_update_session` | Incrementally update active session (toggle checklist, add decisions/notes/pointers) |
178
178
  | `handoff_list_sessions` | List all sessions (open/active/paused/closed) with summary info |
179
179
  | `handoff_get_session` | Get full detail of a specific session by ID |
180
+ | `handoff_fork_session` | Fork a new session from an existing one with context inheritance |
181
+ | `handoff_merge_sessions` | Merge multiple sessions into one with conflict detection |
180
182
 
181
183
  ### Task Management
182
184
 
@@ -184,7 +186,7 @@ manual configuration alternative.
184
186
  |------|---------|
185
187
  | `handoff_list_tasks` | List tasks with filters (status, assignee, milestone, priority, label) |
186
188
  | `handoff_get_task` | Get full task details (notes, done_criteria, schedule, etc.) |
187
- | `handoff_update_task` | Create, update, or move tasks in a hierarchical tree |
189
+ | `handoff_update_task` | Create, update, or move tasks; supports `notes_append` for safe incremental notes |
188
190
  | `handoff_check_criterion` | Toggle a single done_criteria item by index |
189
191
  | `handoff_log_time` | Log hours worked — adds to `actual_hours`, deducts from `remaining_hours` |
190
192
  | `handoff_bulk_update_tasks` | Update multiple tasks in one call (status, schedule, assignee, priority) |
@@ -319,6 +321,7 @@ history_limit = 20 # Max closed sessions to keep
319
321
  done_task_limit = 10 # Max completed tasks to show
320
322
  auto_git_summary = true # Capture git state automatically
321
323
  require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
324
+ multi_session = true # Allow multiple active sessions (default true for new projects)
322
325
  ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
323
326
  timer_provider = "auto" # "auto" | "vscode" | "mcp" | "off"
324
327
  timer_authority_ttl_secs = 30 # Heartbeat freshness TTL for authority.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -62,6 +62,16 @@ description: "Session handoff — load context at start, save at end, track task
62
62
  aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
63
63
  requirement off, set `settings.require_estimate_hours = false`.
64
64
 
65
+ ### Appending to Task Notes
66
+ - Use `notes_append` (not `notes`) in `handoff_update_task` or
67
+ `handoff_bulk_update_tasks` to add text to existing task notes without
68
+ replacing them. The server adds a `--- YYYY-MM-DDTHH:MM:SS` timestamp
69
+ heading automatically.
70
+ - This is safe for multi-agent/multi-step workflows — no read-modify-write
71
+ needed, no risk of losing prior notes.
72
+ - If both `notes` (replace) and `notes_append` are provided in the same call,
73
+ `notes` takes precedence and `notes_append` is ignored.
74
+
65
75
  ### Progressive done_criteria Checking
66
76
  - **Check off `done_criteria` immediately as each item is verified** — do not
67
77
  wait until the entire task is finished. Use `handoff_check_criterion` to
@@ -162,10 +172,26 @@ Before assigning dates to tasks, check available capacity:
162
172
  - `handoff_update_labels` — set the project-level label vocabulary (`labels` array).
163
173
  - `handoff_start_project` — set `started_at` and, with `shift_dates: true`, move every task's dates so the earliest start lands on the project start date.
164
174
 
175
+ ### Multi-session
176
+
177
+ When `multi_session = true` (default for new projects), multiple active sessions
178
+ can coexist. Use `session_id` on load/save/update to target a specific one.
179
+
180
+ - `handoff_fork_session` — branch from an existing session. Inherits decisions,
181
+ context_pointers, references, handoff_notes by default. Sets
182
+ `parent_session_id`. Source can be active, paused, or closed.
183
+ - `handoff_merge_sessions` — combine multiple sessions (append mode). Detects
184
+ duplicate decisions as conflicts. Non-target sources closed by default.
185
+ - **Switch**: `handoff_save_context(pause_session_id: "s-current")` then
186
+ `handoff_load_context(session_id: "s-target")`.
187
+ - **Session fields**: `timeline` (grouping label), `label` (short name),
188
+ `parent_session_id` (fork origin), `related_task_ids` (task association).
189
+
165
190
  ### Session Browsing
166
191
 
167
- - `handoff_list_sessions` — list all sessions with status filter (open/active/paused/closed) and limit.
168
- - `handoff_get_session` get full detail of any session by ID (decisions, checklist, handoff_notes, context_pointers, references).
192
+ - `handoff_list_sessions` — list sessions; filter by status, `timeline`;
193
+ `include_children: true` adds child session arrays for branching visualization.
194
+ - `handoff_get_session` — get full detail of any session by ID (decisions, checklist, handoff_notes, context_pointers, references, timeline, parent_session_id).
169
195
  - Use these to reference decisions or context from past sessions without needing to re-read the full session file.
170
196
 
171
197
  ### Bulk Operations
@@ -157,8 +157,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
157
157
  "confidence": "confirmed",
158
158
  });
159
159
  let sessions_dir = handoff.join("sessions");
160
- decision_recorded_in =
161
- crate::storage::sessions::append_decision_to_active_sessions(&sessions_dir, decision)?;
160
+ decision_recorded_in = crate::storage::sessions::append_decision_to_active_sessions(
161
+ &sessions_dir,
162
+ decision,
163
+ None,
164
+ )?;
162
165
  }
163
166
 
164
167
  let result = json!({
@@ -55,6 +55,19 @@ fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Valu
55
55
  data.priority = Some(priority.to_string());
56
56
  }
57
57
 
58
+ if let Some(notes) = update.get("notes").and_then(|v| v.as_str()) {
59
+ data.notes = Some(notes.to_string());
60
+ } else if let Some(append) = update.get("notes_append").and_then(|v| v.as_str()) {
61
+ let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
62
+ let block = format!("--- {timestamp}\n{append}");
63
+ match &mut data.notes {
64
+ Some(existing) if !existing.is_empty() => {
65
+ existing.push_str(&format!("\n\n{block}"));
66
+ }
67
+ _ => data.notes = Some(block),
68
+ }
69
+ }
70
+
58
71
  if update.get("assignee").is_some() {
59
72
  data.assignee = update
60
73
  .get("assignee")
@@ -0,0 +1,91 @@
1
+ use anyhow::Result;
2
+ use serde_json::{json, Value};
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::sessions::{fork_session, read_session_by_id};
7
+
8
+ pub fn handle(arguments: &Value) -> Result<String> {
9
+ let project_dir = resolve_project_dir(arguments)?;
10
+ let handoff = ensure_handoff_exists(&project_dir)?;
11
+ let sessions_dir = handoff.join("sessions");
12
+
13
+ let source_session_id = arguments
14
+ .get("source_session_id")
15
+ .and_then(|v| v.as_str())
16
+ .ok_or_else(|| anyhow::anyhow!("'source_session_id' parameter is required"))?;
17
+
18
+ let summary = arguments
19
+ .get("summary")
20
+ .and_then(|v| v.as_str())
21
+ .ok_or_else(|| anyhow::anyhow!("'summary' parameter is required"))?;
22
+
23
+ let label = arguments.get("label").and_then(|v| v.as_str());
24
+ let timeline = arguments.get("timeline").and_then(|v| v.as_str());
25
+
26
+ let related_task_ids: Vec<String> = arguments
27
+ .get("related_task_ids")
28
+ .and_then(|v| v.as_array())
29
+ .map(|arr| {
30
+ arr.iter()
31
+ .filter_map(|v| v.as_str().map(String::from))
32
+ .collect()
33
+ })
34
+ .unwrap_or_default();
35
+
36
+ let default_inherit = vec![
37
+ "decisions",
38
+ "context_pointers",
39
+ "references",
40
+ "handoff_notes",
41
+ "environment",
42
+ ];
43
+ let inherit: Vec<&str> = arguments
44
+ .get("inherit")
45
+ .and_then(|v| v.as_array())
46
+ .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
47
+ .unwrap_or(default_inherit);
48
+
49
+ let source = read_session_by_id(&sessions_dir, source_session_id)?.ok_or_else(|| {
50
+ anyhow::anyhow!(
51
+ "Source session not found: {source_session_id}. \
52
+ Use handoff_list_sessions to see available session IDs."
53
+ )
54
+ })?;
55
+
56
+ let forked = fork_session(
57
+ &sessions_dir,
58
+ &source,
59
+ summary,
60
+ label,
61
+ timeline,
62
+ related_task_ids,
63
+ &inherit,
64
+ )?;
65
+
66
+ let forked_id = forked.id.as_deref().unwrap_or("unknown");
67
+
68
+ let result = json!({
69
+ "session_id": forked_id,
70
+ "parent_session_id": source_session_id,
71
+ "status": "active",
72
+ "inherited": inherit,
73
+ });
74
+
75
+ let mut output = format!(
76
+ "Forked session: {}\nSession ID: {}\nParent: {}\nStatus: active\nInherited: {}",
77
+ summary,
78
+ forked_id,
79
+ source_session_id,
80
+ inherit.join(", "),
81
+ );
82
+ if let Some(tl) = forked.timeline.as_deref() {
83
+ output.push_str(&format!("\nTimeline: {tl}"));
84
+ }
85
+ if let Some(lbl) = forked.label.as_deref() {
86
+ output.push_str(&format!("\nLabel: {lbl}"));
87
+ }
88
+ output.push_str(&format!("\n\n{}", serde_json::to_string_pretty(&result)?));
89
+
90
+ Ok(output)
91
+ }
@@ -109,6 +109,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
109
109
  references: extract_array(session, "references"),
110
110
  context_pointers: extract_array(session, "context_pointers"),
111
111
  environment: Some(environment),
112
+ timeline: None,
113
+ label: None,
114
+ parent_session_id: None,
115
+ related_task_ids: Vec::new(),
112
116
  };
113
117
 
114
118
  write_open_session(&sessions_dir, &data)?;
@@ -154,6 +158,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
154
158
  "format": source_format
155
159
  }
156
160
  })),
161
+ timeline: None,
162
+ label: None,
163
+ parent_session_id: None,
164
+ related_task_ids: Vec::new(),
157
165
  };
158
166
 
159
167
  write_open_session(&sessions_dir, &data)?;
@@ -10,10 +10,15 @@ pub fn handle(arguments: &Value) -> Result<String> {
10
10
  let sessions_dir = handoff.join("sessions");
11
11
 
12
12
  let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
13
+ let timeline_filter = arguments.get("timeline").and_then(|v| v.as_str());
13
14
  let limit = arguments
14
15
  .get("limit")
15
16
  .and_then(|v| v.as_u64())
16
17
  .unwrap_or(20) as usize;
18
+ let include_children = arguments
19
+ .get("include_children")
20
+ .and_then(|v| v.as_bool())
21
+ .unwrap_or(false);
17
22
 
18
23
  if !sessions_dir.exists() {
19
24
  return serde_json::to_string_pretty(&json!([])).map_err(Into::into);
@@ -52,7 +57,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
52
57
  .map(String::from)
53
58
  .unwrap_or_else(|| synthesize_id_from_filename(&name));
54
59
 
60
+ let timeline = data.get("timeline").and_then(|v| v.as_str());
61
+ if let Some(tl_filter) = timeline_filter {
62
+ if timeline.is_none_or(|tl| tl != tl_filter) {
63
+ continue;
64
+ }
65
+ }
66
+
55
67
  let summary = data.get("summary").and_then(|v| v.as_str()).unwrap_or("");
68
+ let label = data.get("label").and_then(|v| v.as_str());
69
+ let parent_session_id = data.get("parent_session_id").and_then(|v| v.as_str());
56
70
  let started_at = data.get("started_at").and_then(|v| v.as_str());
57
71
  let ended_at = data.get("ended_at").and_then(|v| v.as_str());
58
72
  let branch = data.get("branch").and_then(|v| v.as_str());
@@ -78,7 +92,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
78
92
  })
79
93
  .unwrap_or(0);
80
94
 
81
- sessions.push(json!({
95
+ let mut entry = json!({
82
96
  "id": id,
83
97
  "status": status,
84
98
  "summary": summary,
@@ -88,7 +102,17 @@ pub fn handle(arguments: &Value) -> Result<String> {
88
102
  "commit": commit,
89
103
  "decisions_count": decisions_count,
90
104
  "checklist_progress": format!("{}/{}", checklist_checked, checklist_count),
91
- }));
105
+ });
106
+ if let Some(tl) = timeline {
107
+ entry["timeline"] = json!(tl);
108
+ }
109
+ if let Some(lbl) = label {
110
+ entry["label"] = json!(lbl);
111
+ }
112
+ if let Some(pid) = parent_session_id {
113
+ entry["parent_session_id"] = json!(pid);
114
+ }
115
+ sessions.push(entry);
92
116
  }
93
117
 
94
118
  sessions.sort_by(|a, b| {
@@ -97,8 +121,42 @@ pub fn handle(arguments: &Value) -> Result<String> {
97
121
  b_time.cmp(a_time)
98
122
  });
99
123
 
124
+ let all_sessions_for_children = if include_children {
125
+ sessions.clone()
126
+ } else {
127
+ Vec::new()
128
+ };
129
+
100
130
  sessions.truncate(limit);
101
131
 
132
+ if include_children {
133
+ for session in &mut sessions {
134
+ let sid = session.get("id").and_then(|v| v.as_str()).unwrap_or("");
135
+ if sid.is_empty() {
136
+ continue;
137
+ }
138
+ let children: Vec<Value> = all_sessions_for_children
139
+ .iter()
140
+ .filter(|s| {
141
+ s.get("parent_session_id")
142
+ .and_then(|v| v.as_str())
143
+ .is_some_and(|pid| pid == sid)
144
+ })
145
+ .map(|s| {
146
+ json!({
147
+ "id": s.get("id").and_then(|v| v.as_str()).unwrap_or(""),
148
+ "summary": s.get("summary").and_then(|v| v.as_str()).unwrap_or(""),
149
+ "status": s.get("status").and_then(|v| v.as_str()).unwrap_or(""),
150
+ "label": s.get("label").and_then(|v| v.as_str()),
151
+ })
152
+ })
153
+ .collect();
154
+ if !children.is_empty() {
155
+ session["children"] = json!(children);
156
+ }
157
+ }
158
+ }
159
+
102
160
  serde_json::to_string_pretty(&sessions).map_err(Into::into)
103
161
  }
104
162
 
@@ -44,6 +44,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
44
44
  let sessions = read_open_sessions(&sessions_dir)?;
45
45
  let paused_sessions = read_paused_sessions(&sessions_dir)?;
46
46
 
47
+ let multi_session = config.settings.multi_session;
48
+
47
49
  let selected_session = if let Some(sid) = target_session_id {
48
50
  let already_active = active_sessions
49
51
  .iter()
@@ -52,7 +54,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
52
54
  active_sessions
53
55
  .into_iter()
54
56
  .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
55
- } else if !active_sessions.is_empty() {
57
+ } else if !multi_session && !active_sessions.is_empty() {
56
58
  let active_ids: Vec<String> = active_sessions
57
59
  .iter()
58
60
  .filter_map(|s| s.id.clone())
@@ -60,7 +62,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
60
62
  anyhow::bail!(
61
63
  "Cannot activate session '{sid}': another session is already active ({}).\n\
62
64
  Use save_context with close_session_id or pause_session_id to \
63
- close/pause the active session first.",
65
+ close/pause the active session first, or enable multi_session in config.",
64
66
  active_ids.join(", ")
65
67
  );
66
68
  } else if activate_session_by_id(&sessions_dir, sid)?.is_some() {
@@ -211,40 +213,23 @@ pub fn handle(arguments: &Value) -> Result<String> {
211
213
 
212
214
  let current_open = read_open_sessions(&sessions_dir)?;
213
215
  if !current_open.is_empty() {
214
- let summaries: Vec<Value> = current_open
215
- .iter()
216
- .map(|s| {
217
- serde_json::json!({
218
- "id": s.id,
219
- "summary": s.summary,
220
- "ended_at": s.ended_at,
221
- "branch": s.branch,
222
- })
223
- })
224
- .collect();
216
+ let summaries: Vec<Value> = current_open.iter().map(session_summary_json).collect();
225
217
  result["open_sessions"] = serde_json::json!(summaries);
226
218
  }
227
219
 
228
220
  let current_paused = read_paused_sessions(&sessions_dir)?;
229
221
  if !current_paused.is_empty() {
230
- let summaries: Vec<Value> = current_paused
231
- .iter()
232
- .map(|s| {
233
- serde_json::json!({
234
- "id": s.id,
235
- "summary": s.summary,
236
- "ended_at": s.ended_at,
237
- "branch": s.branch,
238
- })
239
- })
240
- .collect();
222
+ let summaries: Vec<Value> = current_paused.iter().map(session_summary_json).collect();
241
223
  result["paused_sessions"] = serde_json::json!(summaries);
242
224
  }
243
225
 
244
- let has_active_session = read_active_sessions(&sessions_dir)
245
- .map(|s| !s.is_empty())
246
- .unwrap_or(false);
247
- if !has_active_session {
226
+ let current_active = read_active_sessions(&sessions_dir)?;
227
+ if current_active.len() > 1 {
228
+ let summaries: Vec<Value> = current_active.iter().map(session_summary_json).collect();
229
+ result["active_sessions"] = serde_json::json!(summaries);
230
+ }
231
+
232
+ if current_active.is_empty() {
248
233
  let mut guidance = serde_json::json!({
249
234
  "action": "create_session",
250
235
  "message": "No active session. Before starting work, call handoff_save_context with session_status='active' to establish a session. Include inherited context (decisions, context_pointers, references) from the previous session so your work survives interruptions."
@@ -267,11 +252,34 @@ pub fn handle(arguments: &Value) -> Result<String> {
267
252
  guidance["suggested_fields"] = suggested;
268
253
  }
269
254
  result["session_guidance"] = guidance;
255
+ } else if current_active.len() > 1 && target_session_id.is_none() {
256
+ let summaries: Vec<Value> = current_active.iter().map(session_summary_json).collect();
257
+ result["session_guidance"] = serde_json::json!({
258
+ "action": "select_session",
259
+ "message": "Multiple active sessions. Use session_id to specify which to work with, or create a new session.",
260
+ "active_sessions": summaries
261
+ });
270
262
  }
271
263
 
272
264
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
273
265
  }
274
266
 
267
+ fn session_summary_json(s: &crate::storage::sessions::SessionData) -> Value {
268
+ let mut obj = serde_json::json!({
269
+ "id": s.id,
270
+ "summary": s.summary,
271
+ "ended_at": s.ended_at,
272
+ "branch": s.branch,
273
+ });
274
+ if let Some(ref label) = s.label {
275
+ obj["label"] = serde_json::json!(label);
276
+ }
277
+ if let Some(ref timeline) = s.timeline {
278
+ obj["timeline"] = serde_json::json!(timeline);
279
+ }
280
+ obj
281
+ }
282
+
275
283
  fn collect_active_task_ids(task_tree: &[TaskIndex]) -> Option<Vec<String>> {
276
284
  let mut ids = Vec::new();
277
285
  collect_active_ids_recursive(task_tree, &mut ids);
@@ -0,0 +1,103 @@
1
+ use anyhow::Result;
2
+ use serde_json::{json, Value};
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::sessions::merge_sessions;
7
+
8
+ pub fn handle(arguments: &Value) -> Result<String> {
9
+ let project_dir = resolve_project_dir(arguments)?;
10
+ let handoff = ensure_handoff_exists(&project_dir)?;
11
+ let sessions_dir = handoff.join("sessions");
12
+
13
+ let source_session_ids: Vec<&str> = arguments
14
+ .get("source_session_ids")
15
+ .and_then(|v| v.as_array())
16
+ .ok_or_else(|| anyhow::anyhow!("'source_session_ids' parameter is required"))?
17
+ .iter()
18
+ .filter_map(|v| v.as_str())
19
+ .collect();
20
+
21
+ if source_session_ids.len() < 2 {
22
+ anyhow::bail!("'source_session_ids' must contain at least 2 session IDs");
23
+ }
24
+
25
+ let target_session_id = arguments
26
+ .get("target_session_id")
27
+ .and_then(|v| v.as_str())
28
+ .ok_or_else(|| anyhow::anyhow!("'target_session_id' parameter is required"))?;
29
+
30
+ if !source_session_ids.contains(&target_session_id) {
31
+ anyhow::bail!(
32
+ "target_session_id '{}' must be one of the source_session_ids",
33
+ target_session_id
34
+ );
35
+ }
36
+
37
+ let close_sources = arguments
38
+ .get("close_sources")
39
+ .and_then(|v| v.as_bool())
40
+ .unwrap_or(true);
41
+
42
+ let result = merge_sessions(
43
+ &sessions_dir,
44
+ &source_session_ids,
45
+ target_session_id,
46
+ close_sources,
47
+ )?;
48
+
49
+ let conflicts_json: Vec<Value> = result
50
+ .conflicts
51
+ .iter()
52
+ .map(|c| {
53
+ json!({
54
+ "type": c.conflict_type,
55
+ "description": c.description,
56
+ "session_a": c.session_a,
57
+ "session_b": c.session_b,
58
+ })
59
+ })
60
+ .collect();
61
+
62
+ let output_json = json!({
63
+ "merged_session_id": result.merged_session_id,
64
+ "merged_decisions": result.merged_decisions,
65
+ "merged_notes": result.merged_notes,
66
+ "merged_references": result.merged_references,
67
+ "merged_context_pointers": result.merged_context_pointers,
68
+ "conflicts": conflicts_json,
69
+ "closed_sessions": result.closed_sessions,
70
+ });
71
+
72
+ let mut output = format!(
73
+ "Merged {} sessions into {}\n\
74
+ Merged: {} decisions, {} notes, {} references, {} context_pointers",
75
+ source_session_ids.len(),
76
+ result.merged_session_id,
77
+ result.merged_decisions,
78
+ result.merged_notes,
79
+ result.merged_references,
80
+ result.merged_context_pointers,
81
+ );
82
+
83
+ if !result.conflicts.is_empty() {
84
+ output.push_str(&format!("\nConflicts: {}", result.conflicts.len()));
85
+ for c in &result.conflicts {
86
+ output.push_str(&format!("\n - {}: {}", c.conflict_type, c.description));
87
+ }
88
+ }
89
+
90
+ if !result.closed_sessions.is_empty() {
91
+ output.push_str(&format!(
92
+ "\nClosed source sessions: {}",
93
+ result.closed_sessions.join(", ")
94
+ ));
95
+ }
96
+
97
+ output.push_str(&format!(
98
+ "\n\n{}",
99
+ serde_json::to_string_pretty(&output_json)?
100
+ ));
101
+
102
+ Ok(output)
103
+ }
@@ -7,6 +7,7 @@ pub mod check_criterion;
7
7
  pub mod config;
8
8
  pub mod config_crud;
9
9
  pub mod dashboard;
10
+ pub mod fork_session;
10
11
  pub mod get_session;
11
12
  pub mod get_task;
12
13
  pub mod import_context;
@@ -16,6 +17,7 @@ pub mod list_tasks;
16
17
  pub mod load_context;
17
18
  pub mod log_time;
18
19
  pub mod memory;
20
+ pub mod merge_sessions;
19
21
  pub mod metrics;
20
22
  pub mod milestones;
21
23
  pub mod refer;
@@ -80,6 +82,8 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
80
82
  "handoff_memory_query" => memory::handle_query(arguments),
81
83
  "handoff_memory_delete" => memory::handle_delete(arguments),
82
84
  "handoff_memory_cleanup" => memory::handle_cleanup(arguments),
85
+ "handoff_fork_session" => fork_session::handle(arguments),
86
+ "handoff_merge_sessions" => merge_sessions::handle(arguments),
83
87
  "handoff_timer_start" => timer::handle_start(arguments),
84
88
  "handoff_timer_stop" => timer::handle_stop(arguments),
85
89
  "handoff_timer_get_time" => timer::handle_get_time(arguments),
@@ -79,6 +79,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
79
79
  let git_state = capture_git_state(&project_dir)?;
80
80
  let now = Utc::now().to_rfc3339();
81
81
 
82
+ let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
83
+
82
84
  let handoff_updates = SessionData {
83
85
  version: 2,
84
86
  id: None,
@@ -94,6 +96,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
94
96
  references: extract_array(arguments, "references"),
95
97
  context_pointers: extract_array(arguments, "context_pointers"),
96
98
  environment: arguments.get("environment").cloned(),
99
+ timeline: arguments
100
+ .get("timeline")
101
+ .and_then(|v| v.as_str())
102
+ .map(String::from),
103
+ label: arguments
104
+ .get("label")
105
+ .and_then(|v| v.as_str())
106
+ .map(String::from),
107
+ parent_session_id: None,
108
+ related_task_ids: extract_string_array(arguments, "related_task_ids"),
97
109
  };
98
110
 
99
111
  let (total_closed, path, session_id) = if let Some(id) = close_id {
@@ -103,16 +115,19 @@ pub fn handle(arguments: &Value) -> Result<String> {
103
115
  (0, None, None)
104
116
  } else {
105
117
  let active = read_active_sessions(&sessions_dir)?;
106
- if active.len() > 1 {
107
- let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
108
- anyhow::bail!(
109
- "Multiple active sessions found ({}).\n\
110
- Use close_session_id or pause_session_id to specify which to \
111
- close/pause before saving context.",
112
- active_ids.join(", ")
113
- );
114
- }
115
- if let Some(active_session) = active.first() {
118
+ let target = if let Some(tid) = target_session_id {
119
+ active.iter().find(|s| {
120
+ s.id.as_deref()
121
+ .is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
122
+ })
123
+ } else if active.len() == 1 {
124
+ active.first()
125
+ } else if active.len() > 1 {
126
+ active.iter().last()
127
+ } else {
128
+ None
129
+ };
130
+ if let Some(active_session) = target {
116
131
  let sid = active_session.id.clone().unwrap_or_default();
117
132
  if keep_active {
118
133
  let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
@@ -126,6 +141,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
126
141
  Some(sid),
127
142
  )
128
143
  }
144
+ } else if target_session_id.is_some() {
145
+ anyhow::bail!(
146
+ "session_id '{}' not found among active sessions",
147
+ target_session_id.unwrap_or("")
148
+ );
129
149
  } else {
130
150
  let new_id = generate_session_id();
131
151
  let mut data = handoff_updates.clone();