handoff-mcp-server 0.7.0 → 0.7.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
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.7.0"
147
+ version = "0.7.1"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.7.0",
3
+ "version": "0.7.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>",
@@ -5,8 +5,8 @@ use super::resolve_project_dir;
5
5
  use crate::storage::config::read_config;
6
6
  use crate::storage::referrals::read_referral_summaries;
7
7
  use crate::storage::sessions::{
8
- activate_open_sessions, activate_session_by_id, read_open_sessions, read_paused_sessions,
9
- resume_paused_session_by_id,
8
+ activate_open_sessions, activate_session_by_id, read_active_sessions, read_open_sessions,
9
+ read_paused_sessions, resume_paused_session_by_id,
10
10
  };
11
11
  use crate::storage::tasks::build_task_index;
12
12
  use crate::storage::{ensure_handoff_exists, handoff_dir};
@@ -39,11 +39,30 @@ pub fn handle(arguments: &Value) -> Result<String> {
39
39
 
40
40
  let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
41
41
 
42
+ let active_sessions = read_active_sessions(&sessions_dir)?;
42
43
  let sessions = read_open_sessions(&sessions_dir)?;
43
44
  let paused_sessions = read_paused_sessions(&sessions_dir)?;
44
45
 
45
46
  let selected_session = if let Some(sid) = target_session_id {
46
- if activate_session_by_id(&sessions_dir, sid)?.is_some() {
47
+ let already_active = active_sessions
48
+ .iter()
49
+ .any(|s| s.id.as_deref().is_some_and(|id| id == sid));
50
+ if already_active {
51
+ active_sessions
52
+ .into_iter()
53
+ .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
54
+ } else if !active_sessions.is_empty() {
55
+ let active_ids: Vec<String> = active_sessions
56
+ .iter()
57
+ .filter_map(|s| s.id.clone())
58
+ .collect();
59
+ anyhow::bail!(
60
+ "Cannot activate session '{sid}': another session is already active ({}).\n\
61
+ Use save_context with close_session_id or pause_session_id to \
62
+ close/pause the active session first.",
63
+ active_ids.join(", ")
64
+ );
65
+ } else if activate_session_by_id(&sessions_dir, sid)?.is_some() {
47
66
  sessions
48
67
  .into_iter()
49
68
  .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
@@ -54,6 +73,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
54
73
  } else {
55
74
  None
56
75
  }
76
+ } else if !active_sessions.is_empty() {
77
+ active_sessions.into_iter().last()
78
+ } else if sessions.len() > 1 {
79
+ let open_ids: Vec<String> = sessions.iter().filter_map(|s| s.id.clone()).collect();
80
+ anyhow::bail!(
81
+ "Multiple open sessions found ({}).\n\
82
+ Specify session_id to choose one, or use save_context to \
83
+ close/pause the others first.",
84
+ open_ids.join(", ")
85
+ );
57
86
  } else {
58
87
  activate_open_sessions(&sessions_dir)?;
59
88
  sessions.into_iter().last()
@@ -10,8 +10,8 @@ use crate::storage::ensure_handoff_exists;
10
10
  use crate::storage::git::capture_git_state;
11
11
  use crate::storage::sessions::{
12
12
  close_active_sessions, close_open_sessions, close_session_by_id, enforce_history_limit,
13
- generate_session_id, pause_active_sessions, pause_session_by_id, write_open_session,
14
- SessionData,
13
+ generate_session_id, pause_active_sessions, pause_session_by_id, read_active_sessions,
14
+ write_open_session, SessionData,
15
15
  };
16
16
 
17
17
  pub fn handle(arguments: &Value) -> Result<String> {
@@ -53,6 +53,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
53
53
  let closed_open = close_open_sessions(&sessions_dir)?;
54
54
  closed_open.len()
55
55
  } else {
56
+ let active = read_active_sessions(&sessions_dir)?;
57
+ if active.len() > 1 {
58
+ let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
59
+ anyhow::bail!(
60
+ "Multiple active sessions found ({}).\n\
61
+ Use close_session_id or pause_session_id to specify which to \
62
+ close/pause before saving a new session.",
63
+ active_ids.join(", ")
64
+ );
65
+ }
56
66
  let closed_active = close_active_sessions(&sessions_dir)?;
57
67
  let closed_open = close_open_sessions(&sessions_dir)?;
58
68
  closed_active.len() + closed_open.len()
@@ -1192,3 +1192,119 @@ fn full_pause_resume_lifecycle() {
1192
1192
  "no more paused sessions after resume"
1193
1193
  );
1194
1194
  }
1195
+
1196
+ // --- active session uniqueness tests ---
1197
+
1198
+ #[test]
1199
+ fn load_context_rejects_activate_when_another_session_active() {
1200
+ let dir = setup_project();
1201
+ let pd = dir.path().to_string_lossy().to_string();
1202
+
1203
+ // Create session A and activate it
1204
+ call_tool(
1205
+ "handoff_save_context",
1206
+ json!({ "project_dir": &pd, "summary": "session A" }),
1207
+ );
1208
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1209
+ let text = get_text(&resp);
1210
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1211
+ let sid_a = parsed["session_id"].as_str().unwrap().to_string();
1212
+
1213
+ // Create session B (without closing A — use close_session_id to close only A's open)
1214
+ // Actually: save_context default closes active + open, so A gets closed.
1215
+ // We need to simulate the scenario: write a second open session manually
1216
+ // while A is still active. We'll use pause to keep A alive.
1217
+ call_tool(
1218
+ "handoff_save_context",
1219
+ json!({
1220
+ "project_dir": &pd,
1221
+ "summary": "session B",
1222
+ "pause_session_id": &sid_a
1223
+ }),
1224
+ );
1225
+
1226
+ // Now: A is paused, B is open. Load B (activates it).
1227
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1228
+ assert!(!is_error(&resp));
1229
+ let text = get_text(&resp);
1230
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1231
+ let sid_b = parsed["session_id"].as_str().unwrap().to_string();
1232
+
1233
+ // Try to resume A while B is active — should fail
1234
+ let resp = call_tool(
1235
+ "handoff_load_context",
1236
+ json!({ "project_dir": &pd, "session_id": &sid_a }),
1237
+ );
1238
+ assert!(
1239
+ is_error(&resp),
1240
+ "should reject activating A while B is active: {}",
1241
+ get_text(&resp)
1242
+ );
1243
+ let text = get_text(&resp);
1244
+ assert!(
1245
+ text.contains("already active"),
1246
+ "error should mention active session: {text}"
1247
+ );
1248
+ assert!(
1249
+ text.contains(&sid_b),
1250
+ "error should mention the blocking session ID: {text}"
1251
+ );
1252
+ }
1253
+
1254
+ #[test]
1255
+ fn load_context_allows_reloading_already_active_session() {
1256
+ let dir = setup_project();
1257
+ let pd = dir.path().to_string_lossy().to_string();
1258
+
1259
+ call_tool(
1260
+ "handoff_save_context",
1261
+ json!({ "project_dir": &pd, "summary": "session A" }),
1262
+ );
1263
+
1264
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1265
+ let text = get_text(&resp);
1266
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1267
+ let sid = parsed["session_id"].as_str().unwrap().to_string();
1268
+
1269
+ // Loading the same session again should succeed (idempotent)
1270
+ let resp2 = call_tool(
1271
+ "handoff_load_context",
1272
+ json!({ "project_dir": &pd, "session_id": &sid }),
1273
+ );
1274
+ assert!(
1275
+ !is_error(&resp2),
1276
+ "reloading the same active session should succeed: {}",
1277
+ get_text(&resp2)
1278
+ );
1279
+ let text2 = get_text(&resp2);
1280
+ let parsed2: Value = serde_json::from_str(&text2).unwrap();
1281
+ assert_eq!(parsed2["session_id"].as_str().unwrap(), sid);
1282
+ }
1283
+
1284
+ #[test]
1285
+ fn load_context_returns_active_session_without_open() {
1286
+ let dir = setup_project();
1287
+ let pd = dir.path().to_string_lossy().to_string();
1288
+
1289
+ call_tool(
1290
+ "handoff_save_context",
1291
+ json!({ "project_dir": &pd, "summary": "my session" }),
1292
+ );
1293
+
1294
+ // First load activates the open session
1295
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1296
+ let text = get_text(&resp);
1297
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1298
+ let sid = parsed["session_id"].as_str().unwrap().to_string();
1299
+
1300
+ // Second load (no session_id) should return the active session
1301
+ let resp2 = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1302
+ assert!(!is_error(&resp2), "error: {}", get_text(&resp2));
1303
+ let text2 = get_text(&resp2);
1304
+ let parsed2: Value = serde_json::from_str(&text2).unwrap();
1305
+ assert_eq!(
1306
+ parsed2["session_id"].as_str().unwrap(),
1307
+ sid,
1308
+ "should return the same active session"
1309
+ );
1310
+ }