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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +15 -12
- package/package.json +1 -1
- package/src/mcp/handlers/import_context.rs +11 -2
- package/src/mcp/handlers/load_context.rs +110 -15
- package/src/mcp/handlers/mod.rs +2 -0
- package/src/mcp/handlers/save_context.rs +91 -49
- package/src/mcp/handlers/update_session.rs +188 -0
- package/src/mcp/router.rs +10 -6
- package/src/mcp/tools.rs +70 -3
- package/src/storage/sessions.rs +104 -1
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>",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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> {
|
|
@@ -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
|
|
36
|
-
.get("
|
|
37
|
-
.and_then(|v| v.
|
|
38
|
-
.unwrap_or(
|
|
30
|
+
let is_pause_only = arguments
|
|
31
|
+
.get("pause_only")
|
|
32
|
+
.and_then(|v| v.as_bool())
|
|
33
|
+
.unwrap_or(false);
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
let
|
|
58
|
-
if
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
82
|
+
let handoff_updates = SessionData {
|
|
85
83
|
version: 2,
|
|
86
|
-
id:
|
|
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 =
|
|
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(&
|
|
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.
|
|
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",
|
|
@@ -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
|
|
package/src/storage/sessions.rs
CHANGED
|
@@ -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);
|