handoff-mcp-server 0.8.1 → 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.1"
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.1"
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.1",
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>",
@@ -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> {
@@ -27,11 +27,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
27
27
  .get("pause_active")
28
28
  .and_then(|v| v.as_bool())
29
29
  .unwrap_or(false);
30
- let session_status = arguments
31
- .get("session_status")
32
- .and_then(|v| v.as_str())
33
- .unwrap_or("open");
34
-
35
30
  let is_pause_only = arguments
36
31
  .get("pause_only")
37
32
  .and_then(|v| v.as_bool())
@@ -45,13 +40,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
45
40
 
46
41
  let summary = summary_opt.unwrap_or("");
47
42
 
48
- if session_status != "open" && session_status != "active" {
49
- anyhow::bail!(
50
- "Invalid session_status '{}': must be 'open' or 'active'",
51
- session_status
52
- );
53
- }
54
-
55
43
  let mut total_paused = 0usize;
56
44
  if let Some(id) = pause_id {
57
45
  if pause_session_by_id(&sessions_dir, id)?.is_some() {
@@ -82,37 +70,18 @@ pub fn handle(arguments: &Value) -> Result<String> {
82
70
  return Ok(msg);
83
71
  }
84
72
 
85
- let total_closed = if let Some(id) = close_id {
86
- let closed = close_session_by_id(&sessions_dir, id)?;
87
- if closed.is_some() {
88
- 1
89
- } else {
90
- 0
91
- }
92
- } else if pause_id.is_some() || pause_all {
93
- 0
94
- } else {
95
- let active = read_active_sessions(&sessions_dir)?;
96
- if active.len() > 1 {
97
- let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
98
- anyhow::bail!(
99
- "Multiple active sessions found ({}).\n\
100
- Use close_session_id or pause_session_id to specify which to \
101
- close/pause before saving a new session.",
102
- active_ids.join(", ")
103
- );
104
- }
105
- let closed_active = close_active_sessions(&sessions_dir)?;
106
- closed_active.len()
107
- };
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";
108
78
 
109
79
  let git_state = capture_git_state(&project_dir)?;
110
80
  let now = Utc::now().to_rfc3339();
111
- let session_id = generate_session_id();
112
81
 
113
- let data = SessionData {
82
+ let handoff_updates = SessionData {
114
83
  version: 2,
115
- id: Some(session_id.clone()),
84
+ id: None,
116
85
  ended_at: Some(now),
117
86
  summary: summary.to_string(),
118
87
  branch: Some(git_state.branch),
@@ -127,7 +96,45 @@ pub fn handle(arguments: &Value) -> Result<String> {
127
96
  environment: arguments.get("environment").cloned(),
128
97
  };
129
98
 
130
- 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
+ };
131
138
 
132
139
  let history_limit = if config_path.exists() {
133
140
  read_config(&config_path)
@@ -138,15 +145,21 @@ pub fn handle(arguments: &Value) -> Result<String> {
138
145
  };
139
146
  let removed = enforce_history_limit(&sessions_dir, history_limit)?;
140
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)");
141
154
  let mut msg = format!(
142
155
  "Session saved: {}\nSession ID: {}\nFile: {}",
143
- summary,
144
- session_id,
145
- path.file_name()
146
- .map(|n| n.to_string_lossy().to_string())
147
- .unwrap_or_default()
156
+ summary, sid_display, file_display
148
157
  );
149
158
 
159
+ if keep_active {
160
+ msg.push_str("\nSession kept active (session_status: active)");
161
+ }
162
+
150
163
  if total_paused > 0 {
151
164
  msg.push_str(&format!("\nPaused {} session(s)", total_paused));
152
165
  }
@@ -173,7 +186,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
173
186
  ));
174
187
  }
175
188
 
176
- for w in collect_save_warnings(&data, &project_dir) {
189
+ for w in collect_save_warnings(&handoff_updates, &project_dir) {
177
190
  msg.push_str(&format!("\n{w}"));
178
191
  }
179
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",
@@ -644,6 +644,65 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
644
644
  "required": ["referral_id", "status"]
645
645
  }),
646
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
+ },
647
706
  ]
648
707
  }
649
708
 
@@ -294,6 +294,106 @@ pub fn close_paused_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
294
294
  transition_sessions(sessions_dir, "paused", "closed")
295
295
  }
296
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
+
297
397
  pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
298
398
  if !sessions_dir.exists() {
299
399
  return Ok(0);