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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +15 -12
- package/package.json +1 -1
- package/src/mcp/handlers/load_context.rs +110 -15
- package/src/mcp/handlers/mod.rs +2 -0
- package/src/mcp/handlers/save_context.rs +61 -48
- package/src/mcp/handlers/update_session.rs +188 -0
- package/src/mcp/router.rs +10 -6
- package/src/mcp/tools.rs +62 -3
- package/src/storage/sessions.rs +100 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
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/ │
|
|
23
|
-
│ │──────────────────>│
|
|
24
|
-
│ save_context │ tasks/ │
|
|
25
|
-
│ -
|
|
26
|
-
│ -
|
|
27
|
-
│ -
|
|
28
|
-
│ -
|
|
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
|
|
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. **
|
|
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. **
|
|
97
|
+
3. **Work normally** — create tasks, track progress, make decisions. The active session persists on disk, so progress survives interruptions.
|
|
98
98
|
|
|
99
|
-
4. **
|
|
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
|
|
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.
|
|
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,
|
|
9
|
-
|
|
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(
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
82
|
+
let handoff_updates = SessionData {
|
|
114
83
|
version: 2,
|
|
115
|
-
id:
|
|
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 =
|
|
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(&
|
|
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.
|
|
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
|
|
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": "
|
|
63
|
-
"enum": ["
|
|
64
|
-
"default": "
|
|
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
|
|
package/src/storage/sessions.rs
CHANGED
|
@@ -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);
|