handoff-mcp-server 0.8.0 → 0.10.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/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.8.0"
147
+ version = "0.10.0"
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.8.0"
3
+ version = "0.10.0"
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
@@ -19,17 +19,17 @@ This gets painful fast on multi-session projects.
19
19
  ```
20
20
  Session 1 Session 2
21
21
  ┌──────────────┐ ┌──────────────┐
22
- │ Working... │ .handoff/ │ Loading...
23
- │ │──────────────────>│
24
- │ save_context │ tasks/ │ load_context
25
- │ - summary │ sessions/ │ - tasks
26
- │ - decisions │ config.toml │ - decisions
27
- │ - blockers │ │ - blockers
28
- │ - tasks │ │ - git state
22
+ │ Working... │ .handoff/ │ load_context
23
+ │ │──────────────────>│ ↓ guidance
24
+ │ save_context │ tasks/ │ save_context
25
+ │ - close │ sessions/ │ (active)
26
+ │ - summary │ config.toml │ work...
27
+ │ - decisions │ │ save_context
28
+ │ - blockers │ │ (close)
29
29
  └──────────────┘ └──────────────┘
30
30
  ```
31
31
 
32
- At session end, the agent calls `handoff_save_context` to persist what matters. At session start, it calls `handoff_load_context` to pick up where things left off.
32
+ At session start, the agent calls `handoff_load_context` to pick up where things left off. If no active session exists, the response includes `session_guidance` prompting the agent to establish one via `handoff_save_context` with `session_status: "active"` — this creates a persistent `.active.json` that survives interruptions. At session end, the agent calls `handoff_save_context` (defaulting to `session_status: "closed"`) to close the session.
33
33
 
34
34
  ## Installation
35
35
 
@@ -92,11 +92,11 @@ Add to your Claude Code MCP configuration:
92
92
  └── tasks/ # Task tree (directories + TOML files)
93
93
  ```
94
94
 
95
- 2. **Work normally** — create tasks, track progress, make decisions.
95
+ 2. **Load context** at session start the agent calls `handoff_load_context`. If `session_guidance` is returned, the agent establishes an active session via `handoff_save_context` with `session_status: "active"` before starting work.
96
96
 
97
- 3. **Save context** at session end the agent captures a summary, decisions, blockers, and references.
97
+ 3. **Work normally** create tasks, track progress, make decisions. The active session persists on disk, so progress survives interruptions.
98
98
 
99
- 4. **Load context** at next session start — the agent reads back everything and resumes.
99
+ 4. **Save context** at session end — the agent calls `handoff_save_context` to close the active session with handoff data (summary, decisions, blockers, references).
100
100
 
101
101
  > Add `.handoff/` to your `.gitignore` — it contains local working state, not code.
102
102
 
@@ -106,7 +106,7 @@ Add to your Claude Code MCP configuration:
106
106
  |------|---------|
107
107
  | `handoff_init` | Initialize `.handoff/` directory for a project |
108
108
  | `handoff_load_context` | Load session context, tasks, and git state at session start |
109
- | `handoff_save_context` | Save session summary, decisions, blockers, and references |
109
+ | `handoff_save_context` | Save session state establish an active session (`session_status: "active"`) or close it (default) with handoff data |
110
110
  | `handoff_list_tasks` | List tasks with optional status filter |
111
111
  | `handoff_update_task` | Create, update, or move tasks in a hierarchical tree |
112
112
  | `handoff_get_config` | Read project configuration |
@@ -201,6 +201,9 @@ This project uses handoff-mcp for session continuity.
201
201
 
202
202
  - **Session start**: Call `handoff_load_context` to load previous session state.
203
203
  If not initialized, call `handoff_init` with the project name.
204
+ If `session_guidance` is present, immediately call `handoff_save_context`
205
+ with `session_status: "active"` to establish a persistent session before
206
+ starting work. Include inherited context from the previous session.
204
207
  - **Session end**: Call `handoff_save_context` with a summary, decisions, and blockers.
205
208
  - **During work**: Use `handoff_update_task` to track progress.
206
209
  Mark tasks `in_progress` when starting, `done` when complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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>",
@@ -49,6 +49,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
49
49
  }
50
50
  }
51
51
 
52
+ let skip_session_close = arguments
53
+ .get("skip_session_close")
54
+ .and_then(|v| v.as_bool())
55
+ .unwrap_or(false);
56
+
52
57
  let mut session_saved = false;
53
58
  if let Some(session) = arguments.get("session") {
54
59
  let summary = session
@@ -58,7 +63,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
58
63
  anyhow::anyhow!("'session.summary' is required when session is provided")
59
64
  })?;
60
65
 
61
- close_active_sessions(&sessions_dir)?;
66
+ if !skip_session_close {
67
+ close_active_sessions(&sessions_dir)?;
68
+ }
62
69
  let git_state = capture_git_state(&project_dir)?;
63
70
  let now = Utc::now().to_rfc3339();
64
71
 
@@ -118,7 +125,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
118
125
  session_saved = true;
119
126
  } else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
120
127
  if !raw_notes.is_empty() {
121
- close_active_sessions(&sessions_dir)?;
128
+ if !skip_session_close {
129
+ close_active_sessions(&sessions_dir)?;
130
+ }
122
131
  let git_state = capture_git_state(&project_dir)?;
123
132
  let now = Utc::now().to_rfc3339();
124
133
 
@@ -5,10 +5,11 @@ 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_active_sessions, read_open_sessions,
9
- read_paused_sessions, resume_paused_session_by_id,
8
+ activate_open_sessions, activate_session_by_id, read_active_sessions,
9
+ read_latest_closed_session, read_open_sessions, read_paused_sessions,
10
+ resume_paused_session_by_id,
10
11
  };
11
- use crate::storage::tasks::build_task_index;
12
+ use crate::storage::tasks::{build_task_index, TaskIndex};
12
13
  use crate::storage::{ensure_handoff_exists, handoff_dir};
13
14
 
14
15
  pub fn handle(arguments: &Value) -> Result<String> {
@@ -139,19 +140,63 @@ pub fn handle(arguments: &Value) -> Result<String> {
139
140
  }
140
141
  }
141
142
 
142
- if let Some(notes) = result.get("handoff_notes").and_then(|v| v.as_array()) {
143
- let suggestions: Vec<&str> = notes
144
- .iter()
145
- .filter(|n| {
146
- n.get("category")
147
- .and_then(|c| c.as_str())
148
- .is_some_and(|c| c == "suggestion")
149
- })
150
- .filter_map(|n| n.get("note").and_then(|v| v.as_str()))
151
- .collect();
152
- if !suggestions.is_empty() {
153
- result["next_actions"] = serde_json::json!(suggestions);
143
+ if let Some(prev) = read_latest_closed_session(&sessions_dir)? {
144
+ let prev_val = serde_json::to_value(&prev).unwrap_or_default();
145
+ let mut prev_obj = serde_json::json!({
146
+ "summary": prev.summary,
147
+ "ended_at": prev.ended_at,
148
+ "branch": prev.branch,
149
+ "commit": prev.commit,
150
+ });
151
+ if let Some(ref id) = prev.id {
152
+ prev_obj["id"] = serde_json::json!(id);
153
+ }
154
+ for key in [
155
+ "decisions",
156
+ "handoff_notes",
157
+ "context_pointers",
158
+ "checklist",
159
+ "references",
160
+ "blockers",
161
+ ] {
162
+ if let Some(val) = prev_val.get(key) {
163
+ if val.as_array().is_some_and(|a| !a.is_empty()) {
164
+ prev_obj[key] = val.clone();
165
+ }
166
+ }
154
167
  }
168
+ if let Some(env) = prev_val.get("environment") {
169
+ if !env.is_null() {
170
+ prev_obj["environment"] = env.clone();
171
+ }
172
+ }
173
+ result["previous_session"] = prev_obj;
174
+ }
175
+
176
+ let notes_sources: Vec<&Value> = [
177
+ result.get("handoff_notes"),
178
+ result
179
+ .get("previous_session")
180
+ .and_then(|ps| ps.get("handoff_notes")),
181
+ ]
182
+ .into_iter()
183
+ .flatten()
184
+ .collect();
185
+
186
+ let suggestions: Vec<&str> = notes_sources
187
+ .iter()
188
+ .filter_map(|v| v.as_array())
189
+ .flatten()
190
+ .filter(|n| {
191
+ n.get("category")
192
+ .and_then(|c| c.as_str())
193
+ .is_some_and(|c| c == "suggestion")
194
+ })
195
+ .filter_map(|n| n.get("note").and_then(|v| v.as_str()))
196
+ .collect();
197
+
198
+ if !suggestions.is_empty() {
199
+ result["next_actions"] = serde_json::json!(suggestions);
155
200
  }
156
201
 
157
202
  if !config.settings.context_files.is_empty() {
@@ -180,5 +225,55 @@ pub fn handle(arguments: &Value) -> Result<String> {
180
225
  result["paused_sessions"] = serde_json::json!(summaries);
181
226
  }
182
227
 
228
+ let has_active_session = read_active_sessions(&sessions_dir)
229
+ .map(|s| !s.is_empty())
230
+ .unwrap_or(false);
231
+ if !has_active_session {
232
+ let mut guidance = serde_json::json!({
233
+ "action": "create_session",
234
+ "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."
235
+ });
236
+ if let Some(prev) = result.get("previous_session") {
237
+ let mut suggested = serde_json::json!({});
238
+ if let Some(summary) = prev.get("summary").and_then(|v| v.as_str()) {
239
+ suggested["summary"] = serde_json::json!(format!("Continuing: {summary}"));
240
+ }
241
+ for key in ["decisions", "context_pointers", "references"] {
242
+ if let Some(val) = prev.get(key) {
243
+ if val.as_array().is_some_and(|a| !a.is_empty()) {
244
+ suggested[key] = val.clone();
245
+ }
246
+ }
247
+ }
248
+ if let Some(task_ids) = collect_active_task_ids(&task_tree) {
249
+ suggested["related_task_ids"] = serde_json::json!(task_ids);
250
+ }
251
+ guidance["suggested_fields"] = suggested;
252
+ }
253
+ result["session_guidance"] = guidance;
254
+ }
255
+
183
256
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
184
257
  }
258
+
259
+ fn collect_active_task_ids(task_tree: &[TaskIndex]) -> Option<Vec<String>> {
260
+ let mut ids = Vec::new();
261
+ collect_active_ids_recursive(task_tree, &mut ids);
262
+ if ids.is_empty() {
263
+ None
264
+ } else {
265
+ Some(ids)
266
+ }
267
+ }
268
+
269
+ fn collect_active_ids_recursive(tasks: &[TaskIndex], ids: &mut Vec<String>) {
270
+ for task in tasks {
271
+ if matches!(
272
+ task.status.as_str(),
273
+ "in_progress" | "blocked" | "todo" | "review"
274
+ ) {
275
+ ids.push(task.id.clone());
276
+ }
277
+ collect_active_ids_recursive(&task.children, ids);
278
+ }
279
+ }
@@ -9,6 +9,7 @@ pub mod load_context;
9
9
  pub mod refer;
10
10
  pub mod referrals;
11
11
  pub mod save_context;
12
+ pub mod update_session;
12
13
  pub mod update_task;
13
14
 
14
15
  use std::path::PathBuf;
@@ -42,6 +43,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
42
43
  "handoff_refer" => refer::handle(arguments),
43
44
  "handoff_list_referrals" => referrals::handle_list(arguments),
44
45
  "handoff_update_referral" => referrals::handle_update(arguments),
46
+ "handoff_update_session" => update_session::handle(arguments),
45
47
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
46
48
  };
47
49
 
@@ -9,9 +9,9 @@ use crate::storage::config::read_config;
9
9
  use crate::storage::ensure_handoff_exists;
10
10
  use crate::storage::git::capture_git_state;
11
11
  use crate::storage::sessions::{
12
- close_active_sessions, close_session_by_id, enforce_history_limit, generate_session_id,
13
- pause_active_sessions, pause_session_by_id, read_active_sessions, write_session_with_status,
14
- SessionData,
12
+ close_session_by_id, enforce_history_limit, generate_session_id, pause_active_sessions,
13
+ pause_session_by_id, read_active_sessions, update_active_session,
14
+ update_and_close_active_session, write_session_with_status, SessionData,
15
15
  };
16
16
 
17
17
  pub fn handle(arguments: &Value) -> Result<String> {
@@ -21,29 +21,25 @@ pub fn handle(arguments: &Value) -> Result<String> {
21
21
  let sessions_dir = handoff.join("sessions");
22
22
  let config_path = handoff.join("config.toml");
23
23
 
24
- let summary = arguments
25
- .get("summary")
26
- .and_then(|v| v.as_str())
27
- .ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
28
-
29
24
  let close_id = arguments.get("close_session_id").and_then(|v| v.as_str());
30
25
  let pause_id = arguments.get("pause_session_id").and_then(|v| v.as_str());
31
26
  let pause_all = arguments
32
27
  .get("pause_active")
33
28
  .and_then(|v| v.as_bool())
34
29
  .unwrap_or(false);
35
- let session_status = arguments
36
- .get("session_status")
37
- .and_then(|v| v.as_str())
38
- .unwrap_or("open");
30
+ let is_pause_only = arguments
31
+ .get("pause_only")
32
+ .and_then(|v| v.as_bool())
33
+ .unwrap_or(false);
39
34
 
40
- if session_status != "open" && session_status != "active" {
41
- anyhow::bail!(
42
- "Invalid session_status '{}': must be 'open' or 'active'",
43
- session_status
44
- );
35
+ let summary_opt = arguments.get("summary").and_then(|v| v.as_str());
36
+
37
+ if !is_pause_only && summary_opt.is_none() {
38
+ anyhow::bail!("'summary' is required (unless pause_only=true)");
45
39
  }
46
40
 
41
+ let summary = summary_opt.unwrap_or("");
42
+
47
43
  let mut total_paused = 0usize;
48
44
  if let Some(id) = pause_id {
49
45
  if pause_session_by_id(&sessions_dir, id)?.is_some() {
@@ -53,37 +49,39 @@ pub fn handle(arguments: &Value) -> Result<String> {
53
49
  total_paused = pause_active_sessions(&sessions_dir)?.len();
54
50
  }
55
51
 
56
- let total_closed = if let Some(id) = close_id {
57
- let closed = close_session_by_id(&sessions_dir, id)?;
58
- if closed.is_some() {
59
- 1
60
- } else {
61
- 0
52
+ if is_pause_only {
53
+ let mut msg = String::new();
54
+ if total_paused > 0 {
55
+ msg.push_str(&format!("Paused {} session(s)", total_paused));
62
56
  }
63
- } else if pause_id.is_some() || pause_all {
64
- 0
65
- } else {
66
- let active = read_active_sessions(&sessions_dir)?;
67
- if active.len() > 1 {
68
- let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
69
- anyhow::bail!(
70
- "Multiple active sessions found ({}).\n\
71
- Use close_session_id or pause_session_id to specify which to \
72
- close/pause before saving a new session.",
73
- active_ids.join(", ")
74
- );
57
+ if let Some(id) = pause_id {
58
+ if total_paused == 0 {
59
+ msg.push_str(&format!(
60
+ "Warning: pause_session_id '{id}' not found among active/open sessions"
61
+ ));
62
+ }
75
63
  }
76
- let closed_active = close_active_sessions(&sessions_dir)?;
77
- closed_active.len()
78
- };
64
+ if !pause_all && pause_id.is_none() {
65
+ msg.push_str("Warning: pause_only=true requires pause_session_id or pause_active=true");
66
+ }
67
+ if msg.is_empty() {
68
+ msg.push_str("No sessions were paused (none active)");
69
+ }
70
+ return Ok(msg);
71
+ }
72
+
73
+ let session_status = arguments
74
+ .get("session_status")
75
+ .and_then(|v| v.as_str())
76
+ .unwrap_or("closed");
77
+ let keep_active = session_status == "active";
79
78
 
80
79
  let git_state = capture_git_state(&project_dir)?;
81
80
  let now = Utc::now().to_rfc3339();
82
- let session_id = generate_session_id();
83
81
 
84
- let data = SessionData {
82
+ let handoff_updates = SessionData {
85
83
  version: 2,
86
- id: Some(session_id.clone()),
84
+ id: None,
87
85
  ended_at: Some(now),
88
86
  summary: summary.to_string(),
89
87
  branch: Some(git_state.branch),
@@ -98,7 +96,45 @@ pub fn handle(arguments: &Value) -> Result<String> {
98
96
  environment: arguments.get("environment").cloned(),
99
97
  };
100
98
 
101
- let path = write_session_with_status(&sessions_dir, &data, session_status)?;
99
+ let (total_closed, path, session_id) = if let Some(id) = close_id {
100
+ let closed = close_session_by_id(&sessions_dir, id)?;
101
+ (if closed.is_some() { 1 } else { 0 }, None, None)
102
+ } else if pause_id.is_some() || pause_all {
103
+ (0, None, None)
104
+ } else {
105
+ 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() {
116
+ let sid = active_session.id.clone().unwrap_or_default();
117
+ if keep_active {
118
+ let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
119
+ (0, updated_path, Some(sid))
120
+ } else {
121
+ let closed_path =
122
+ update_and_close_active_session(&sessions_dir, &sid, &handoff_updates)?;
123
+ (
124
+ if closed_path.is_some() { 1 } else { 0 },
125
+ closed_path,
126
+ Some(sid),
127
+ )
128
+ }
129
+ } else {
130
+ let new_id = generate_session_id();
131
+ let mut data = handoff_updates.clone();
132
+ data.id = Some(new_id.clone());
133
+ let target_status = if keep_active { "active" } else { "closed" };
134
+ let p = write_session_with_status(&sessions_dir, &data, target_status)?;
135
+ (0, Some(p), Some(new_id))
136
+ }
137
+ };
102
138
 
103
139
  let history_limit = if config_path.exists() {
104
140
  read_config(&config_path)
@@ -109,22 +145,28 @@ pub fn handle(arguments: &Value) -> Result<String> {
109
145
  };
110
146
  let removed = enforce_history_limit(&sessions_dir, history_limit)?;
111
147
 
148
+ let file_display = path
149
+ .as_ref()
150
+ .and_then(|p| p.file_name())
151
+ .map(|n| n.to_string_lossy().to_string())
152
+ .unwrap_or_else(|| "(none)".to_string());
153
+ let sid_display = session_id.as_deref().unwrap_or("(no active session)");
112
154
  let mut msg = format!(
113
155
  "Session saved: {}\nSession ID: {}\nFile: {}",
114
- summary,
115
- session_id,
116
- path.file_name()
117
- .map(|n| n.to_string_lossy().to_string())
118
- .unwrap_or_default()
156
+ summary, sid_display, file_display
119
157
  );
120
158
 
159
+ if keep_active {
160
+ msg.push_str("\nSession kept active (session_status: active)");
161
+ }
162
+
121
163
  if total_paused > 0 {
122
164
  msg.push_str(&format!("\nPaused {} session(s)", total_paused));
123
165
  }
124
166
  if let Some(id) = pause_id {
125
167
  if total_paused == 0 {
126
168
  msg.push_str(&format!(
127
- "\nWarning: pause_session_id '{id}' not found among active sessions"
169
+ "\nWarning: pause_session_id '{id}' not found among active/open sessions"
128
170
  ));
129
171
  }
130
172
  }
@@ -144,7 +186,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
144
186
  ));
145
187
  }
146
188
 
147
- for w in collect_save_warnings(&data, &project_dir) {
189
+ for w in collect_save_warnings(&handoff_updates, &project_dir) {
148
190
  msg.push_str(&format!("\n{w}"));
149
191
  }
150
192
 
@@ -0,0 +1,188 @@
1
+ use anyhow::{Context, Result};
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::sessions::{read_active_sessions, SessionData};
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 active = read_active_sessions(&sessions_dir)?;
14
+ if active.is_empty() {
15
+ anyhow::bail!(
16
+ "No active session. Call save_context with session_status='active' to create one first."
17
+ );
18
+ }
19
+ if active.len() > 1 {
20
+ let ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
21
+ anyhow::bail!(
22
+ "Multiple active sessions ({}). Use save_context to resolve.",
23
+ ids.join(", ")
24
+ );
25
+ }
26
+
27
+ let session = &active[0];
28
+ let sid = session.id.as_deref().unwrap_or("");
29
+
30
+ let checklist_index = arguments
31
+ .get("checklist_index")
32
+ .and_then(|v| v.as_u64())
33
+ .map(|v| v as usize);
34
+ let checklist_checked = arguments.get("checklist_checked").and_then(|v| v.as_bool());
35
+ let add_checklist_item = arguments.get("add_checklist_item").and_then(|v| v.as_str());
36
+ let add_decision = arguments.get("add_decision");
37
+ let add_handoff_note = arguments.get("add_handoff_note");
38
+ let add_context_pointer = arguments.get("add_context_pointer");
39
+
40
+ let mut data = session.clone();
41
+ let mut changes = Vec::new();
42
+
43
+ if let Some(idx) = checklist_index {
44
+ let checked = checklist_checked.unwrap_or(true);
45
+ if idx >= data.checklist.len() {
46
+ anyhow::bail!(
47
+ "checklist_index {idx} out of range (session has {} items)",
48
+ data.checklist.len()
49
+ );
50
+ }
51
+ if let Some(item) = data.checklist.get_mut(idx) {
52
+ item["checked"] = serde_json::json!(checked);
53
+ let item_text = item
54
+ .get("item")
55
+ .and_then(|v| v.as_str())
56
+ .unwrap_or("(unknown)");
57
+ changes.push(format!(
58
+ "checklist[{idx}] '{}' → {}",
59
+ item_text,
60
+ if checked { "checked" } else { "unchecked" }
61
+ ));
62
+ }
63
+ }
64
+
65
+ if let Some(item_text) = add_checklist_item {
66
+ let owner = arguments
67
+ .get("checklist_owner")
68
+ .and_then(|v| v.as_str())
69
+ .unwrap_or("ai");
70
+ data.checklist.push(serde_json::json!({
71
+ "item": item_text,
72
+ "checked": false,
73
+ "owner": owner
74
+ }));
75
+ changes.push(format!("added checklist item: '{item_text}'"));
76
+ }
77
+
78
+ if let Some(decision) = add_decision {
79
+ if decision.is_object() {
80
+ data.decisions.push(decision.clone());
81
+ let desc = decision
82
+ .get("decision")
83
+ .and_then(|v| v.as_str())
84
+ .unwrap_or("(decision)");
85
+ changes.push(format!("added decision: '{desc}'"));
86
+ } else {
87
+ anyhow::bail!("add_decision must be an object with at least a 'decision' field");
88
+ }
89
+ }
90
+
91
+ if let Some(note) = add_handoff_note {
92
+ if note.is_object() {
93
+ data.handoff_notes.push(note.clone());
94
+ let text = note
95
+ .get("note")
96
+ .and_then(|v| v.as_str())
97
+ .unwrap_or("(note)");
98
+ changes.push(format!("added handoff_note: '{}'", truncate(text, 60)));
99
+ } else {
100
+ anyhow::bail!("add_handoff_note must be an object with at least a 'note' field");
101
+ }
102
+ }
103
+
104
+ if let Some(pointer) = add_context_pointer {
105
+ if pointer.is_object() {
106
+ data.context_pointers.push(pointer.clone());
107
+ let path = pointer
108
+ .get("path")
109
+ .and_then(|v| v.as_str())
110
+ .unwrap_or("(path)");
111
+ changes.push(format!("added context_pointer: '{path}'"));
112
+ } else {
113
+ anyhow::bail!("add_context_pointer must be an object with at least a 'path' field");
114
+ }
115
+ }
116
+
117
+ if changes.is_empty() {
118
+ anyhow::bail!(
119
+ "No updates specified. Use checklist_index, add_checklist_item, \
120
+ add_decision, add_handoff_note, or add_context_pointer."
121
+ );
122
+ }
123
+
124
+ write_active_session_data(&sessions_dir, sid, &data)?;
125
+
126
+ let mut msg = format!("Session {} updated:", sid);
127
+ for c in &changes {
128
+ msg.push_str(&format!("\n - {c}"));
129
+ }
130
+
131
+ let checked_count = data
132
+ .checklist
133
+ .iter()
134
+ .filter(|item| {
135
+ item.get("checked")
136
+ .and_then(|v| v.as_bool())
137
+ .unwrap_or(false)
138
+ })
139
+ .count();
140
+ msg.push_str(&format!(
141
+ "\nChecklist: {}/{} checked",
142
+ checked_count,
143
+ data.checklist.len()
144
+ ));
145
+
146
+ Ok(msg)
147
+ }
148
+
149
+ fn truncate(s: &str, max: usize) -> String {
150
+ if s.len() <= max {
151
+ s.to_string()
152
+ } else {
153
+ format!("{}...", &s[..max])
154
+ }
155
+ }
156
+
157
+ fn write_active_session_data(
158
+ sessions_dir: &std::path::Path,
159
+ session_id: &str,
160
+ data: &SessionData,
161
+ ) -> Result<()> {
162
+ let suffix = ".active.json";
163
+
164
+ for entry in std::fs::read_dir(sessions_dir)? {
165
+ let entry = entry?;
166
+ let name = entry.file_name().to_string_lossy().to_string();
167
+ if !name.ends_with(suffix) {
168
+ continue;
169
+ }
170
+
171
+ let content = std::fs::read_to_string(entry.path())
172
+ .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
173
+ let existing: SessionData = serde_json::from_str(&content)
174
+ .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
175
+
176
+ let file_id = existing.id.as_deref().unwrap_or("");
177
+ if file_id != session_id {
178
+ continue;
179
+ }
180
+
181
+ let updated = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
182
+ std::fs::write(entry.path(), updated)
183
+ .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
184
+ return Ok(());
185
+ }
186
+
187
+ anyhow::bail!("Active session '{session_id}' not found on disk")
188
+ }
package/src/mcp/router.rs CHANGED
@@ -48,18 +48,22 @@ fn handle_initialize() -> JsonRpcResponse {
48
48
  ## Session Start\n\
49
49
  1. Call handoff_load_context (no args needed — uses cwd)\n\
50
50
  2. If it returns \"not initialized\", call handoff_init with the project name\n\
51
- 3. Check the `next_actions` array first these are the previous session's recommended next steps. Do not re-verify work the previous session already completed\n\n\
51
+ 3. If session_guidance is present, call handoff_save_context with session_status='active' to establish a persistent session before starting work\n\
52
+ 4. Check the `next_actions` array first — these are the previous session's recommended next steps. Do not re-verify work the previous session already completed\n\n\
53
+ ## During Work — Progressive Updates\n\
54
+ - Use handoff_update_task to create/update tasks as work progresses\n\
55
+ - Mark tasks in_progress when starting, done when complete\n\
56
+ - Use handoff_check_criterion to check off task done_criteria as each item is verified — do not wait until the task is fully done\n\
57
+ - Use handoff_update_session to progressively update the active session: toggle checklist items, append decisions, notes, or context pointers\n\
58
+ - When work reaches a point requiring user confirmation, set the task status to review\n\
59
+ - Record decisions as they are made, not just at session end\n\n\
52
60
  ## Session End\n\
53
61
  1. Call handoff_save_context with:\n\
54
62
  - summary: one-line description of what was accomplished\n\
55
63
  - decisions: key decisions made (with reason and confidence)\n\
56
64
  - blockers: anything preventing progress\n\
57
65
  - handoff_notes: caution/context/suggestion for the next session\n\
58
- - context_pointers: files and line ranges the next session should look at\n\n\
59
- ## During Work\n\
60
- - Use handoff_update_task to create/update tasks as work progresses\n\
61
- - Mark tasks in_progress when starting, done when complete\n\
62
- - Record decisions as they are made, not just at session end"
66
+ - context_pointers: files and line ranges the next session should look at"
63
67
  .to_string(),
64
68
  ),
65
69
  };
package/src/mcp/tools.rs CHANGED
@@ -59,9 +59,9 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
59
59
  },
60
60
  "session_status": {
61
61
  "type": "string",
62
- "description": "Status of the new session: 'open' (default) = saved but not activated, allows multiple open sessions; 'active' = immediately active for this session.",
63
- "enum": ["open", "active"],
64
- "default": "open"
62
+ "description": "Session status after save. 'closed' (default) = close the active session as history. 'active' = keep or create an active session (use at session start to establish a persistent session that survives interruptions).",
63
+ "enum": ["closed", "active"],
64
+ "default": "closed"
65
65
  },
66
66
  "close_session_id": {
67
67
  "type": "string",
@@ -75,6 +75,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
75
75
  "type": "boolean",
76
76
  "description": "If true, pause all active sessions instead of closing them. Cannot be combined with close_session_id."
77
77
  },
78
+ "pause_only": {
79
+ "type": "boolean",
80
+ "description": "If true, only pause sessions (via pause_session_id or pause_active) without creating a new session. Useful for session switching. When true, summary is optional."
81
+ },
78
82
  "decisions": {
79
83
  "type": "array",
80
84
  "description": "Decisions made during this session",
@@ -479,6 +483,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
479
483
  "raw_notes": {
480
484
  "type": "string",
481
485
  "description": "Free-form text that couldn't be structured. Saved as a handoff_note with category 'context'."
486
+ },
487
+ "skip_session_close": {
488
+ "type": "boolean",
489
+ "description": "If true, do not close active sessions before creating the import session. Default false."
482
490
  }
483
491
  },
484
492
  "required": ["source"],
@@ -636,6 +644,65 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
636
644
  "required": ["referral_id", "status"]
637
645
  }),
638
646
  },
647
+ ToolDefinition {
648
+ name: "handoff_update_session".to_string(),
649
+ description: "Incrementally update the active session. Toggle checklist items, add decisions, notes, or context pointers without resending everything. Use during work for progressive updates.".to_string(),
650
+ input_schema: json!({
651
+ "type": "object",
652
+ "properties": {
653
+ "project_dir": {
654
+ "type": "string",
655
+ "description": "Project directory path. Defaults to current working directory."
656
+ },
657
+ "checklist_index": {
658
+ "type": "integer",
659
+ "description": "0-based index of a checklist item to toggle."
660
+ },
661
+ "checklist_checked": {
662
+ "type": "boolean",
663
+ "description": "Set the checklist item to checked (true) or unchecked (false). Defaults to true."
664
+ },
665
+ "add_checklist_item": {
666
+ "type": "string",
667
+ "description": "Text of a new checklist item to add (unchecked)."
668
+ },
669
+ "checklist_owner": {
670
+ "type": "string",
671
+ "description": "Owner for the new checklist item: 'user' or 'ai'. Defaults to 'ai'.",
672
+ "enum": ["user", "ai"]
673
+ },
674
+ "add_decision": {
675
+ "type": "object",
676
+ "description": "A decision to append to the session.",
677
+ "properties": {
678
+ "decision": { "type": "string" },
679
+ "reason": { "type": "string" },
680
+ "confidence": { "type": "string", "enum": ["confirmed", "estimated", "unverified"] }
681
+ },
682
+ "required": ["decision"]
683
+ },
684
+ "add_handoff_note": {
685
+ "type": "object",
686
+ "description": "A handoff note to append to the session.",
687
+ "properties": {
688
+ "note": { "type": "string" },
689
+ "category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
690
+ },
691
+ "required": ["note"]
692
+ },
693
+ "add_context_pointer": {
694
+ "type": "object",
695
+ "description": "A context pointer to append to the session.",
696
+ "properties": {
697
+ "path": { "type": "string" },
698
+ "reason": { "type": "string" },
699
+ "lines": { "type": "string" }
700
+ },
701
+ "required": ["path"]
702
+ }
703
+ }
704
+ }),
705
+ },
639
706
  ]
640
707
  }
641
708
 
@@ -273,7 +273,10 @@ pub fn pause_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
273
273
  }
274
274
 
275
275
  pub fn pause_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
276
- transition_session_by_id(sessions_dir, session_id, "active", "paused")
276
+ if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "paused")? {
277
+ return Ok(Some(path));
278
+ }
279
+ transition_session_by_id(sessions_dir, session_id, "open", "paused")
277
280
  }
278
281
 
279
282
  pub fn resume_paused_session_by_id(
@@ -291,6 +294,106 @@ pub fn close_paused_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
291
294
  transition_sessions(sessions_dir, "paused", "closed")
292
295
  }
293
296
 
297
+ pub fn read_latest_closed_session(sessions_dir: &Path) -> Result<Option<SessionData>> {
298
+ let mut sessions = read_sessions_by_status(sessions_dir, "closed")?;
299
+ sessions.sort_by(|a, b| a.ended_at.cmp(&b.ended_at));
300
+ Ok(sessions.into_iter().last())
301
+ }
302
+
303
+ fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
304
+ data.summary = updates.summary.clone();
305
+ data.ended_at = updates.ended_at.clone();
306
+ data.branch = updates.branch.clone();
307
+ data.commit = updates.commit.clone();
308
+ data.dirty_files = updates.dirty_files.clone();
309
+ data.decisions = updates.decisions.clone();
310
+ data.blockers = updates.blockers.clone();
311
+ data.checklist = updates.checklist.clone();
312
+ data.handoff_notes = updates.handoff_notes.clone();
313
+ data.references = updates.references.clone();
314
+ data.context_pointers = updates.context_pointers.clone();
315
+ if updates.environment.is_some() {
316
+ data.environment = updates.environment.clone();
317
+ }
318
+ }
319
+
320
+ fn find_and_update_active_session(
321
+ sessions_dir: &Path,
322
+ session_id: &str,
323
+ updates: &SessionData,
324
+ transition_to: Option<&str>,
325
+ ) -> Result<Option<PathBuf>> {
326
+ let suffix = ".active.json";
327
+
328
+ if !sessions_dir.exists() {
329
+ return Ok(None);
330
+ }
331
+
332
+ for entry in std::fs::read_dir(sessions_dir)? {
333
+ let entry = entry?;
334
+ let name = entry.file_name().to_string_lossy().to_string();
335
+ if !name.ends_with(suffix) {
336
+ continue;
337
+ }
338
+
339
+ let content = std::fs::read_to_string(entry.path())
340
+ .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
341
+ let mut data: SessionData = serde_json::from_str(&content)
342
+ .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
343
+
344
+ let file_id = data.id.as_deref().unwrap_or("").to_string();
345
+ let synthesized = if file_id.is_empty() {
346
+ synthesize_id_from_filename(&name)
347
+ } else {
348
+ file_id
349
+ };
350
+
351
+ if synthesized != session_id {
352
+ continue;
353
+ }
354
+
355
+ apply_session_updates(&mut data, updates);
356
+
357
+ let updated_content =
358
+ serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
359
+ std::fs::write(entry.path(), &updated_content)
360
+ .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
361
+
362
+ if let Some(target_status) = transition_to {
363
+ let target_suffix = format!(".{target_status}.json");
364
+ let new_name = name.replace(suffix, &target_suffix);
365
+ let new_path = sessions_dir.join(&new_name);
366
+ std::fs::rename(entry.path(), &new_path).with_context(|| {
367
+ format!(
368
+ "Failed to transition session active->{target_status}: {}",
369
+ entry.path().display()
370
+ )
371
+ })?;
372
+ return Ok(Some(new_path));
373
+ }
374
+
375
+ return Ok(Some(entry.path()));
376
+ }
377
+
378
+ Ok(None)
379
+ }
380
+
381
+ pub fn update_and_close_active_session(
382
+ sessions_dir: &Path,
383
+ session_id: &str,
384
+ updates: &SessionData,
385
+ ) -> Result<Option<PathBuf>> {
386
+ find_and_update_active_session(sessions_dir, session_id, updates, Some("closed"))
387
+ }
388
+
389
+ pub fn update_active_session(
390
+ sessions_dir: &Path,
391
+ session_id: &str,
392
+ updates: &SessionData,
393
+ ) -> Result<Option<PathBuf>> {
394
+ find_and_update_active_session(sessions_dir, session_id, updates, None)
395
+ }
396
+
294
397
  pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
295
398
  if !sessions_dir.exists() {
296
399
  return Ok(0);