handoff-mcp-server 0.16.0 → 0.17.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 +4 -1
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +28 -2
- package/src/mcp/handlers/auto_schedule.rs +5 -2
- package/src/mcp/handlers/bulk_update.rs +13 -0
- package/src/mcp/handlers/fork_session.rs +91 -0
- package/src/mcp/handlers/import_context.rs +8 -0
- package/src/mcp/handlers/list_sessions.rs +60 -2
- package/src/mcp/handlers/load_context.rs +36 -28
- package/src/mcp/handlers/merge_sessions.rs +103 -0
- package/src/mcp/handlers/mod.rs +4 -0
- package/src/mcp/handlers/save_context.rs +30 -10
- package/src/mcp/handlers/update_session.rs +15 -8
- package/src/mcp/handlers/update_task.rs +9 -0
- package/src/mcp/tools.rs +100 -0
- package/src/storage/config.rs +9 -1
- package/src/storage/sessions.rs +299 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -177,6 +177,8 @@ manual configuration alternative.
|
|
|
177
177
|
| `handoff_update_session` | Incrementally update active session (toggle checklist, add decisions/notes/pointers) |
|
|
178
178
|
| `handoff_list_sessions` | List all sessions (open/active/paused/closed) with summary info |
|
|
179
179
|
| `handoff_get_session` | Get full detail of a specific session by ID |
|
|
180
|
+
| `handoff_fork_session` | Fork a new session from an existing one with context inheritance |
|
|
181
|
+
| `handoff_merge_sessions` | Merge multiple sessions into one with conflict detection |
|
|
180
182
|
|
|
181
183
|
### Task Management
|
|
182
184
|
|
|
@@ -184,7 +186,7 @@ manual configuration alternative.
|
|
|
184
186
|
|------|---------|
|
|
185
187
|
| `handoff_list_tasks` | List tasks with filters (status, assignee, milestone, priority, label) |
|
|
186
188
|
| `handoff_get_task` | Get full task details (notes, done_criteria, schedule, etc.) |
|
|
187
|
-
| `handoff_update_task` | Create, update, or move tasks
|
|
189
|
+
| `handoff_update_task` | Create, update, or move tasks; supports `notes_append` for safe incremental notes |
|
|
188
190
|
| `handoff_check_criterion` | Toggle a single done_criteria item by index |
|
|
189
191
|
| `handoff_log_time` | Log hours worked — adds to `actual_hours`, deducts from `remaining_hours` |
|
|
190
192
|
| `handoff_bulk_update_tasks` | Update multiple tasks in one call (status, schedule, assignee, priority) |
|
|
@@ -319,6 +321,7 @@ history_limit = 20 # Max closed sessions to keep
|
|
|
319
321
|
done_task_limit = 10 # Max completed tasks to show
|
|
320
322
|
auto_git_summary = true # Capture git state automatically
|
|
321
323
|
require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
|
|
324
|
+
multi_session = true # Allow multiple active sessions (default true for new projects)
|
|
322
325
|
ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
|
|
323
326
|
timer_provider = "auto" # "auto" | "vscode" | "mcp" | "off"
|
|
324
327
|
timer_authority_ttl_secs = 30 # Heartbeat freshness TTL for authority.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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>",
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -62,6 +62,16 @@ description: "Session handoff — load context at start, save at end, track task
|
|
|
62
62
|
aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
|
|
63
63
|
requirement off, set `settings.require_estimate_hours = false`.
|
|
64
64
|
|
|
65
|
+
### Appending to Task Notes
|
|
66
|
+
- Use `notes_append` (not `notes`) in `handoff_update_task` or
|
|
67
|
+
`handoff_bulk_update_tasks` to add text to existing task notes without
|
|
68
|
+
replacing them. The server adds a `--- YYYY-MM-DDTHH:MM:SS` timestamp
|
|
69
|
+
heading automatically.
|
|
70
|
+
- This is safe for multi-agent/multi-step workflows — no read-modify-write
|
|
71
|
+
needed, no risk of losing prior notes.
|
|
72
|
+
- If both `notes` (replace) and `notes_append` are provided in the same call,
|
|
73
|
+
`notes` takes precedence and `notes_append` is ignored.
|
|
74
|
+
|
|
65
75
|
### Progressive done_criteria Checking
|
|
66
76
|
- **Check off `done_criteria` immediately as each item is verified** — do not
|
|
67
77
|
wait until the entire task is finished. Use `handoff_check_criterion` to
|
|
@@ -162,10 +172,26 @@ Before assigning dates to tasks, check available capacity:
|
|
|
162
172
|
- `handoff_update_labels` — set the project-level label vocabulary (`labels` array).
|
|
163
173
|
- `handoff_start_project` — set `started_at` and, with `shift_dates: true`, move every task's dates so the earliest start lands on the project start date.
|
|
164
174
|
|
|
175
|
+
### Multi-session
|
|
176
|
+
|
|
177
|
+
When `multi_session = true` (default for new projects), multiple active sessions
|
|
178
|
+
can coexist. Use `session_id` on load/save/update to target a specific one.
|
|
179
|
+
|
|
180
|
+
- `handoff_fork_session` — branch from an existing session. Inherits decisions,
|
|
181
|
+
context_pointers, references, handoff_notes by default. Sets
|
|
182
|
+
`parent_session_id`. Source can be active, paused, or closed.
|
|
183
|
+
- `handoff_merge_sessions` — combine multiple sessions (append mode). Detects
|
|
184
|
+
duplicate decisions as conflicts. Non-target sources closed by default.
|
|
185
|
+
- **Switch**: `handoff_save_context(pause_session_id: "s-current")` then
|
|
186
|
+
`handoff_load_context(session_id: "s-target")`.
|
|
187
|
+
- **Session fields**: `timeline` (grouping label), `label` (short name),
|
|
188
|
+
`parent_session_id` (fork origin), `related_task_ids` (task association).
|
|
189
|
+
|
|
165
190
|
### Session Browsing
|
|
166
191
|
|
|
167
|
-
- `handoff_list_sessions` — list
|
|
168
|
-
|
|
192
|
+
- `handoff_list_sessions` — list sessions; filter by status, `timeline`;
|
|
193
|
+
`include_children: true` adds child session arrays for branching visualization.
|
|
194
|
+
- `handoff_get_session` — get full detail of any session by ID (decisions, checklist, handoff_notes, context_pointers, references, timeline, parent_session_id).
|
|
169
195
|
- Use these to reference decisions or context from past sessions without needing to re-read the full session file.
|
|
170
196
|
|
|
171
197
|
### Bulk Operations
|
|
@@ -157,8 +157,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
157
157
|
"confidence": "confirmed",
|
|
158
158
|
});
|
|
159
159
|
let sessions_dir = handoff.join("sessions");
|
|
160
|
-
decision_recorded_in =
|
|
161
|
-
|
|
160
|
+
decision_recorded_in = crate::storage::sessions::append_decision_to_active_sessions(
|
|
161
|
+
&sessions_dir,
|
|
162
|
+
decision,
|
|
163
|
+
None,
|
|
164
|
+
)?;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
let result = json!({
|
|
@@ -55,6 +55,19 @@ fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Valu
|
|
|
55
55
|
data.priority = Some(priority.to_string());
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
if let Some(notes) = update.get("notes").and_then(|v| v.as_str()) {
|
|
59
|
+
data.notes = Some(notes.to_string());
|
|
60
|
+
} else if let Some(append) = update.get("notes_append").and_then(|v| v.as_str()) {
|
|
61
|
+
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
|
|
62
|
+
let block = format!("--- {timestamp}\n{append}");
|
|
63
|
+
match &mut data.notes {
|
|
64
|
+
Some(existing) if !existing.is_empty() => {
|
|
65
|
+
existing.push_str(&format!("\n\n{block}"));
|
|
66
|
+
}
|
|
67
|
+
_ => data.notes = Some(block),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
if update.get("assignee").is_some() {
|
|
59
72
|
data.assignee = update
|
|
60
73
|
.get("assignee")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use serde_json::{json, Value};
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
use crate::storage::sessions::{fork_session, read_session_by_id};
|
|
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 source_session_id = arguments
|
|
14
|
+
.get("source_session_id")
|
|
15
|
+
.and_then(|v| v.as_str())
|
|
16
|
+
.ok_or_else(|| anyhow::anyhow!("'source_session_id' parameter is required"))?;
|
|
17
|
+
|
|
18
|
+
let summary = arguments
|
|
19
|
+
.get("summary")
|
|
20
|
+
.and_then(|v| v.as_str())
|
|
21
|
+
.ok_or_else(|| anyhow::anyhow!("'summary' parameter is required"))?;
|
|
22
|
+
|
|
23
|
+
let label = arguments.get("label").and_then(|v| v.as_str());
|
|
24
|
+
let timeline = arguments.get("timeline").and_then(|v| v.as_str());
|
|
25
|
+
|
|
26
|
+
let related_task_ids: Vec<String> = arguments
|
|
27
|
+
.get("related_task_ids")
|
|
28
|
+
.and_then(|v| v.as_array())
|
|
29
|
+
.map(|arr| {
|
|
30
|
+
arr.iter()
|
|
31
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
32
|
+
.collect()
|
|
33
|
+
})
|
|
34
|
+
.unwrap_or_default();
|
|
35
|
+
|
|
36
|
+
let default_inherit = vec![
|
|
37
|
+
"decisions",
|
|
38
|
+
"context_pointers",
|
|
39
|
+
"references",
|
|
40
|
+
"handoff_notes",
|
|
41
|
+
"environment",
|
|
42
|
+
];
|
|
43
|
+
let inherit: Vec<&str> = arguments
|
|
44
|
+
.get("inherit")
|
|
45
|
+
.and_then(|v| v.as_array())
|
|
46
|
+
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
|
|
47
|
+
.unwrap_or(default_inherit);
|
|
48
|
+
|
|
49
|
+
let source = read_session_by_id(&sessions_dir, source_session_id)?.ok_or_else(|| {
|
|
50
|
+
anyhow::anyhow!(
|
|
51
|
+
"Source session not found: {source_session_id}. \
|
|
52
|
+
Use handoff_list_sessions to see available session IDs."
|
|
53
|
+
)
|
|
54
|
+
})?;
|
|
55
|
+
|
|
56
|
+
let forked = fork_session(
|
|
57
|
+
&sessions_dir,
|
|
58
|
+
&source,
|
|
59
|
+
summary,
|
|
60
|
+
label,
|
|
61
|
+
timeline,
|
|
62
|
+
related_task_ids,
|
|
63
|
+
&inherit,
|
|
64
|
+
)?;
|
|
65
|
+
|
|
66
|
+
let forked_id = forked.id.as_deref().unwrap_or("unknown");
|
|
67
|
+
|
|
68
|
+
let result = json!({
|
|
69
|
+
"session_id": forked_id,
|
|
70
|
+
"parent_session_id": source_session_id,
|
|
71
|
+
"status": "active",
|
|
72
|
+
"inherited": inherit,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let mut output = format!(
|
|
76
|
+
"Forked session: {}\nSession ID: {}\nParent: {}\nStatus: active\nInherited: {}",
|
|
77
|
+
summary,
|
|
78
|
+
forked_id,
|
|
79
|
+
source_session_id,
|
|
80
|
+
inherit.join(", "),
|
|
81
|
+
);
|
|
82
|
+
if let Some(tl) = forked.timeline.as_deref() {
|
|
83
|
+
output.push_str(&format!("\nTimeline: {tl}"));
|
|
84
|
+
}
|
|
85
|
+
if let Some(lbl) = forked.label.as_deref() {
|
|
86
|
+
output.push_str(&format!("\nLabel: {lbl}"));
|
|
87
|
+
}
|
|
88
|
+
output.push_str(&format!("\n\n{}", serde_json::to_string_pretty(&result)?));
|
|
89
|
+
|
|
90
|
+
Ok(output)
|
|
91
|
+
}
|
|
@@ -109,6 +109,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
109
109
|
references: extract_array(session, "references"),
|
|
110
110
|
context_pointers: extract_array(session, "context_pointers"),
|
|
111
111
|
environment: Some(environment),
|
|
112
|
+
timeline: None,
|
|
113
|
+
label: None,
|
|
114
|
+
parent_session_id: None,
|
|
115
|
+
related_task_ids: Vec::new(),
|
|
112
116
|
};
|
|
113
117
|
|
|
114
118
|
write_open_session(&sessions_dir, &data)?;
|
|
@@ -154,6 +158,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
154
158
|
"format": source_format
|
|
155
159
|
}
|
|
156
160
|
})),
|
|
161
|
+
timeline: None,
|
|
162
|
+
label: None,
|
|
163
|
+
parent_session_id: None,
|
|
164
|
+
related_task_ids: Vec::new(),
|
|
157
165
|
};
|
|
158
166
|
|
|
159
167
|
write_open_session(&sessions_dir, &data)?;
|
|
@@ -10,10 +10,15 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
10
10
|
let sessions_dir = handoff.join("sessions");
|
|
11
11
|
|
|
12
12
|
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
13
|
+
let timeline_filter = arguments.get("timeline").and_then(|v| v.as_str());
|
|
13
14
|
let limit = arguments
|
|
14
15
|
.get("limit")
|
|
15
16
|
.and_then(|v| v.as_u64())
|
|
16
17
|
.unwrap_or(20) as usize;
|
|
18
|
+
let include_children = arguments
|
|
19
|
+
.get("include_children")
|
|
20
|
+
.and_then(|v| v.as_bool())
|
|
21
|
+
.unwrap_or(false);
|
|
17
22
|
|
|
18
23
|
if !sessions_dir.exists() {
|
|
19
24
|
return serde_json::to_string_pretty(&json!([])).map_err(Into::into);
|
|
@@ -52,7 +57,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
52
57
|
.map(String::from)
|
|
53
58
|
.unwrap_or_else(|| synthesize_id_from_filename(&name));
|
|
54
59
|
|
|
60
|
+
let timeline = data.get("timeline").and_then(|v| v.as_str());
|
|
61
|
+
if let Some(tl_filter) = timeline_filter {
|
|
62
|
+
if timeline.is_none_or(|tl| tl != tl_filter) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
let summary = data.get("summary").and_then(|v| v.as_str()).unwrap_or("");
|
|
68
|
+
let label = data.get("label").and_then(|v| v.as_str());
|
|
69
|
+
let parent_session_id = data.get("parent_session_id").and_then(|v| v.as_str());
|
|
56
70
|
let started_at = data.get("started_at").and_then(|v| v.as_str());
|
|
57
71
|
let ended_at = data.get("ended_at").and_then(|v| v.as_str());
|
|
58
72
|
let branch = data.get("branch").and_then(|v| v.as_str());
|
|
@@ -78,7 +92,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
78
92
|
})
|
|
79
93
|
.unwrap_or(0);
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
let mut entry = json!({
|
|
82
96
|
"id": id,
|
|
83
97
|
"status": status,
|
|
84
98
|
"summary": summary,
|
|
@@ -88,7 +102,17 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
88
102
|
"commit": commit,
|
|
89
103
|
"decisions_count": decisions_count,
|
|
90
104
|
"checklist_progress": format!("{}/{}", checklist_checked, checklist_count),
|
|
91
|
-
})
|
|
105
|
+
});
|
|
106
|
+
if let Some(tl) = timeline {
|
|
107
|
+
entry["timeline"] = json!(tl);
|
|
108
|
+
}
|
|
109
|
+
if let Some(lbl) = label {
|
|
110
|
+
entry["label"] = json!(lbl);
|
|
111
|
+
}
|
|
112
|
+
if let Some(pid) = parent_session_id {
|
|
113
|
+
entry["parent_session_id"] = json!(pid);
|
|
114
|
+
}
|
|
115
|
+
sessions.push(entry);
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
sessions.sort_by(|a, b| {
|
|
@@ -97,8 +121,42 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
97
121
|
b_time.cmp(a_time)
|
|
98
122
|
});
|
|
99
123
|
|
|
124
|
+
let all_sessions_for_children = if include_children {
|
|
125
|
+
sessions.clone()
|
|
126
|
+
} else {
|
|
127
|
+
Vec::new()
|
|
128
|
+
};
|
|
129
|
+
|
|
100
130
|
sessions.truncate(limit);
|
|
101
131
|
|
|
132
|
+
if include_children {
|
|
133
|
+
for session in &mut sessions {
|
|
134
|
+
let sid = session.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
|
135
|
+
if sid.is_empty() {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
let children: Vec<Value> = all_sessions_for_children
|
|
139
|
+
.iter()
|
|
140
|
+
.filter(|s| {
|
|
141
|
+
s.get("parent_session_id")
|
|
142
|
+
.and_then(|v| v.as_str())
|
|
143
|
+
.is_some_and(|pid| pid == sid)
|
|
144
|
+
})
|
|
145
|
+
.map(|s| {
|
|
146
|
+
json!({
|
|
147
|
+
"id": s.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
|
148
|
+
"summary": s.get("summary").and_then(|v| v.as_str()).unwrap_or(""),
|
|
149
|
+
"status": s.get("status").and_then(|v| v.as_str()).unwrap_or(""),
|
|
150
|
+
"label": s.get("label").and_then(|v| v.as_str()),
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
.collect();
|
|
154
|
+
if !children.is_empty() {
|
|
155
|
+
session["children"] = json!(children);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
serde_json::to_string_pretty(&sessions).map_err(Into::into)
|
|
103
161
|
}
|
|
104
162
|
|
|
@@ -44,6 +44,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
44
44
|
let sessions = read_open_sessions(&sessions_dir)?;
|
|
45
45
|
let paused_sessions = read_paused_sessions(&sessions_dir)?;
|
|
46
46
|
|
|
47
|
+
let multi_session = config.settings.multi_session;
|
|
48
|
+
|
|
47
49
|
let selected_session = if let Some(sid) = target_session_id {
|
|
48
50
|
let already_active = active_sessions
|
|
49
51
|
.iter()
|
|
@@ -52,7 +54,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
52
54
|
active_sessions
|
|
53
55
|
.into_iter()
|
|
54
56
|
.find(|s| s.id.as_deref().is_some_and(|id| id == sid))
|
|
55
|
-
} else if !active_sessions.is_empty() {
|
|
57
|
+
} else if !multi_session && !active_sessions.is_empty() {
|
|
56
58
|
let active_ids: Vec<String> = active_sessions
|
|
57
59
|
.iter()
|
|
58
60
|
.filter_map(|s| s.id.clone())
|
|
@@ -60,7 +62,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
60
62
|
anyhow::bail!(
|
|
61
63
|
"Cannot activate session '{sid}': another session is already active ({}).\n\
|
|
62
64
|
Use save_context with close_session_id or pause_session_id to \
|
|
63
|
-
close/pause the active session first.",
|
|
65
|
+
close/pause the active session first, or enable multi_session in config.",
|
|
64
66
|
active_ids.join(", ")
|
|
65
67
|
);
|
|
66
68
|
} else if activate_session_by_id(&sessions_dir, sid)?.is_some() {
|
|
@@ -211,40 +213,23 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
211
213
|
|
|
212
214
|
let current_open = read_open_sessions(&sessions_dir)?;
|
|
213
215
|
if !current_open.is_empty() {
|
|
214
|
-
let summaries: Vec<Value> = current_open
|
|
215
|
-
.iter()
|
|
216
|
-
.map(|s| {
|
|
217
|
-
serde_json::json!({
|
|
218
|
-
"id": s.id,
|
|
219
|
-
"summary": s.summary,
|
|
220
|
-
"ended_at": s.ended_at,
|
|
221
|
-
"branch": s.branch,
|
|
222
|
-
})
|
|
223
|
-
})
|
|
224
|
-
.collect();
|
|
216
|
+
let summaries: Vec<Value> = current_open.iter().map(session_summary_json).collect();
|
|
225
217
|
result["open_sessions"] = serde_json::json!(summaries);
|
|
226
218
|
}
|
|
227
219
|
|
|
228
220
|
let current_paused = read_paused_sessions(&sessions_dir)?;
|
|
229
221
|
if !current_paused.is_empty() {
|
|
230
|
-
let summaries: Vec<Value> = current_paused
|
|
231
|
-
.iter()
|
|
232
|
-
.map(|s| {
|
|
233
|
-
serde_json::json!({
|
|
234
|
-
"id": s.id,
|
|
235
|
-
"summary": s.summary,
|
|
236
|
-
"ended_at": s.ended_at,
|
|
237
|
-
"branch": s.branch,
|
|
238
|
-
})
|
|
239
|
-
})
|
|
240
|
-
.collect();
|
|
222
|
+
let summaries: Vec<Value> = current_paused.iter().map(session_summary_json).collect();
|
|
241
223
|
result["paused_sessions"] = serde_json::json!(summaries);
|
|
242
224
|
}
|
|
243
225
|
|
|
244
|
-
let
|
|
245
|
-
|
|
246
|
-
.
|
|
247
|
-
|
|
226
|
+
let current_active = read_active_sessions(&sessions_dir)?;
|
|
227
|
+
if current_active.len() > 1 {
|
|
228
|
+
let summaries: Vec<Value> = current_active.iter().map(session_summary_json).collect();
|
|
229
|
+
result["active_sessions"] = serde_json::json!(summaries);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if current_active.is_empty() {
|
|
248
233
|
let mut guidance = serde_json::json!({
|
|
249
234
|
"action": "create_session",
|
|
250
235
|
"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."
|
|
@@ -267,11 +252,34 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
267
252
|
guidance["suggested_fields"] = suggested;
|
|
268
253
|
}
|
|
269
254
|
result["session_guidance"] = guidance;
|
|
255
|
+
} else if current_active.len() > 1 && target_session_id.is_none() {
|
|
256
|
+
let summaries: Vec<Value> = current_active.iter().map(session_summary_json).collect();
|
|
257
|
+
result["session_guidance"] = serde_json::json!({
|
|
258
|
+
"action": "select_session",
|
|
259
|
+
"message": "Multiple active sessions. Use session_id to specify which to work with, or create a new session.",
|
|
260
|
+
"active_sessions": summaries
|
|
261
|
+
});
|
|
270
262
|
}
|
|
271
263
|
|
|
272
264
|
serde_json::to_string_pretty(&result).context("Failed to serialize context")
|
|
273
265
|
}
|
|
274
266
|
|
|
267
|
+
fn session_summary_json(s: &crate::storage::sessions::SessionData) -> Value {
|
|
268
|
+
let mut obj = serde_json::json!({
|
|
269
|
+
"id": s.id,
|
|
270
|
+
"summary": s.summary,
|
|
271
|
+
"ended_at": s.ended_at,
|
|
272
|
+
"branch": s.branch,
|
|
273
|
+
});
|
|
274
|
+
if let Some(ref label) = s.label {
|
|
275
|
+
obj["label"] = serde_json::json!(label);
|
|
276
|
+
}
|
|
277
|
+
if let Some(ref timeline) = s.timeline {
|
|
278
|
+
obj["timeline"] = serde_json::json!(timeline);
|
|
279
|
+
}
|
|
280
|
+
obj
|
|
281
|
+
}
|
|
282
|
+
|
|
275
283
|
fn collect_active_task_ids(task_tree: &[TaskIndex]) -> Option<Vec<String>> {
|
|
276
284
|
let mut ids = Vec::new();
|
|
277
285
|
collect_active_ids_recursive(task_tree, &mut ids);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use serde_json::{json, Value};
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
use crate::storage::sessions::merge_sessions;
|
|
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 source_session_ids: Vec<&str> = arguments
|
|
14
|
+
.get("source_session_ids")
|
|
15
|
+
.and_then(|v| v.as_array())
|
|
16
|
+
.ok_or_else(|| anyhow::anyhow!("'source_session_ids' parameter is required"))?
|
|
17
|
+
.iter()
|
|
18
|
+
.filter_map(|v| v.as_str())
|
|
19
|
+
.collect();
|
|
20
|
+
|
|
21
|
+
if source_session_ids.len() < 2 {
|
|
22
|
+
anyhow::bail!("'source_session_ids' must contain at least 2 session IDs");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let target_session_id = arguments
|
|
26
|
+
.get("target_session_id")
|
|
27
|
+
.and_then(|v| v.as_str())
|
|
28
|
+
.ok_or_else(|| anyhow::anyhow!("'target_session_id' parameter is required"))?;
|
|
29
|
+
|
|
30
|
+
if !source_session_ids.contains(&target_session_id) {
|
|
31
|
+
anyhow::bail!(
|
|
32
|
+
"target_session_id '{}' must be one of the source_session_ids",
|
|
33
|
+
target_session_id
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let close_sources = arguments
|
|
38
|
+
.get("close_sources")
|
|
39
|
+
.and_then(|v| v.as_bool())
|
|
40
|
+
.unwrap_or(true);
|
|
41
|
+
|
|
42
|
+
let result = merge_sessions(
|
|
43
|
+
&sessions_dir,
|
|
44
|
+
&source_session_ids,
|
|
45
|
+
target_session_id,
|
|
46
|
+
close_sources,
|
|
47
|
+
)?;
|
|
48
|
+
|
|
49
|
+
let conflicts_json: Vec<Value> = result
|
|
50
|
+
.conflicts
|
|
51
|
+
.iter()
|
|
52
|
+
.map(|c| {
|
|
53
|
+
json!({
|
|
54
|
+
"type": c.conflict_type,
|
|
55
|
+
"description": c.description,
|
|
56
|
+
"session_a": c.session_a,
|
|
57
|
+
"session_b": c.session_b,
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
.collect();
|
|
61
|
+
|
|
62
|
+
let output_json = json!({
|
|
63
|
+
"merged_session_id": result.merged_session_id,
|
|
64
|
+
"merged_decisions": result.merged_decisions,
|
|
65
|
+
"merged_notes": result.merged_notes,
|
|
66
|
+
"merged_references": result.merged_references,
|
|
67
|
+
"merged_context_pointers": result.merged_context_pointers,
|
|
68
|
+
"conflicts": conflicts_json,
|
|
69
|
+
"closed_sessions": result.closed_sessions,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let mut output = format!(
|
|
73
|
+
"Merged {} sessions into {}\n\
|
|
74
|
+
Merged: {} decisions, {} notes, {} references, {} context_pointers",
|
|
75
|
+
source_session_ids.len(),
|
|
76
|
+
result.merged_session_id,
|
|
77
|
+
result.merged_decisions,
|
|
78
|
+
result.merged_notes,
|
|
79
|
+
result.merged_references,
|
|
80
|
+
result.merged_context_pointers,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if !result.conflicts.is_empty() {
|
|
84
|
+
output.push_str(&format!("\nConflicts: {}", result.conflicts.len()));
|
|
85
|
+
for c in &result.conflicts {
|
|
86
|
+
output.push_str(&format!("\n - {}: {}", c.conflict_type, c.description));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if !result.closed_sessions.is_empty() {
|
|
91
|
+
output.push_str(&format!(
|
|
92
|
+
"\nClosed source sessions: {}",
|
|
93
|
+
result.closed_sessions.join(", ")
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
output.push_str(&format!(
|
|
98
|
+
"\n\n{}",
|
|
99
|
+
serde_json::to_string_pretty(&output_json)?
|
|
100
|
+
));
|
|
101
|
+
|
|
102
|
+
Ok(output)
|
|
103
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -7,6 +7,7 @@ pub mod check_criterion;
|
|
|
7
7
|
pub mod config;
|
|
8
8
|
pub mod config_crud;
|
|
9
9
|
pub mod dashboard;
|
|
10
|
+
pub mod fork_session;
|
|
10
11
|
pub mod get_session;
|
|
11
12
|
pub mod get_task;
|
|
12
13
|
pub mod import_context;
|
|
@@ -16,6 +17,7 @@ pub mod list_tasks;
|
|
|
16
17
|
pub mod load_context;
|
|
17
18
|
pub mod log_time;
|
|
18
19
|
pub mod memory;
|
|
20
|
+
pub mod merge_sessions;
|
|
19
21
|
pub mod metrics;
|
|
20
22
|
pub mod milestones;
|
|
21
23
|
pub mod refer;
|
|
@@ -80,6 +82,8 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
80
82
|
"handoff_memory_query" => memory::handle_query(arguments),
|
|
81
83
|
"handoff_memory_delete" => memory::handle_delete(arguments),
|
|
82
84
|
"handoff_memory_cleanup" => memory::handle_cleanup(arguments),
|
|
85
|
+
"handoff_fork_session" => fork_session::handle(arguments),
|
|
86
|
+
"handoff_merge_sessions" => merge_sessions::handle(arguments),
|
|
83
87
|
"handoff_timer_start" => timer::handle_start(arguments),
|
|
84
88
|
"handoff_timer_stop" => timer::handle_stop(arguments),
|
|
85
89
|
"handoff_timer_get_time" => timer::handle_get_time(arguments),
|
|
@@ -79,6 +79,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
79
79
|
let git_state = capture_git_state(&project_dir)?;
|
|
80
80
|
let now = Utc::now().to_rfc3339();
|
|
81
81
|
|
|
82
|
+
let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
|
|
83
|
+
|
|
82
84
|
let handoff_updates = SessionData {
|
|
83
85
|
version: 2,
|
|
84
86
|
id: None,
|
|
@@ -94,6 +96,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
94
96
|
references: extract_array(arguments, "references"),
|
|
95
97
|
context_pointers: extract_array(arguments, "context_pointers"),
|
|
96
98
|
environment: arguments.get("environment").cloned(),
|
|
99
|
+
timeline: arguments
|
|
100
|
+
.get("timeline")
|
|
101
|
+
.and_then(|v| v.as_str())
|
|
102
|
+
.map(String::from),
|
|
103
|
+
label: arguments
|
|
104
|
+
.get("label")
|
|
105
|
+
.and_then(|v| v.as_str())
|
|
106
|
+
.map(String::from),
|
|
107
|
+
parent_session_id: None,
|
|
108
|
+
related_task_ids: extract_string_array(arguments, "related_task_ids"),
|
|
97
109
|
};
|
|
98
110
|
|
|
99
111
|
let (total_closed, path, session_id) = if let Some(id) = close_id {
|
|
@@ -103,16 +115,19 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
103
115
|
(0, None, None)
|
|
104
116
|
} else {
|
|
105
117
|
let active = read_active_sessions(&sessions_dir)?;
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
let target = if let Some(tid) = target_session_id {
|
|
119
|
+
active.iter().find(|s| {
|
|
120
|
+
s.id.as_deref()
|
|
121
|
+
.is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
|
|
122
|
+
})
|
|
123
|
+
} else if active.len() == 1 {
|
|
124
|
+
active.first()
|
|
125
|
+
} else if active.len() > 1 {
|
|
126
|
+
active.iter().last()
|
|
127
|
+
} else {
|
|
128
|
+
None
|
|
129
|
+
};
|
|
130
|
+
if let Some(active_session) = target {
|
|
116
131
|
let sid = active_session.id.clone().unwrap_or_default();
|
|
117
132
|
if keep_active {
|
|
118
133
|
let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
|
|
@@ -126,6 +141,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
126
141
|
Some(sid),
|
|
127
142
|
)
|
|
128
143
|
}
|
|
144
|
+
} else if target_session_id.is_some() {
|
|
145
|
+
anyhow::bail!(
|
|
146
|
+
"session_id '{}' not found among active sessions",
|
|
147
|
+
target_session_id.unwrap_or("")
|
|
148
|
+
);
|
|
129
149
|
} else {
|
|
130
150
|
let new_id = generate_session_id();
|
|
131
151
|
let mut data = handoff_updates.clone();
|
|
@@ -10,21 +10,28 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
10
10
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
11
11
|
let sessions_dir = handoff.join("sessions");
|
|
12
12
|
|
|
13
|
+
let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
|
|
14
|
+
|
|
13
15
|
let active = read_active_sessions(&sessions_dir)?;
|
|
14
16
|
if active.is_empty() {
|
|
15
17
|
anyhow::bail!(
|
|
16
18
|
"No active session. Call save_context with session_status='active' to create one first."
|
|
17
19
|
);
|
|
18
20
|
}
|
|
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
21
|
|
|
27
|
-
let session =
|
|
22
|
+
let session = if let Some(tid) = target_session_id {
|
|
23
|
+
active
|
|
24
|
+
.iter()
|
|
25
|
+
.find(|s| {
|
|
26
|
+
s.id.as_deref()
|
|
27
|
+
.is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
|
|
28
|
+
})
|
|
29
|
+
.ok_or_else(|| anyhow::anyhow!("session_id '{tid}' not found among active sessions"))?
|
|
30
|
+
} else if active.len() == 1 {
|
|
31
|
+
&active[0]
|
|
32
|
+
} else {
|
|
33
|
+
active.last().unwrap()
|
|
34
|
+
};
|
|
28
35
|
let sid = session.id.as_deref().unwrap_or("");
|
|
29
36
|
|
|
30
37
|
let checklist_index = arguments
|
|
@@ -247,6 +247,15 @@ fn handle_update(
|
|
|
247
247
|
}
|
|
248
248
|
if let Some(notes) = task_val.get("notes").and_then(|v| v.as_str()) {
|
|
249
249
|
data.notes = Some(notes.to_string());
|
|
250
|
+
} else if let Some(append) = task_val.get("notes_append").and_then(|v| v.as_str()) {
|
|
251
|
+
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
|
|
252
|
+
let block = format!("--- {timestamp}\n{append}");
|
|
253
|
+
match &mut data.notes {
|
|
254
|
+
Some(existing) if !existing.is_empty() => {
|
|
255
|
+
existing.push_str(&format!("\n\n{block}"));
|
|
256
|
+
}
|
|
257
|
+
_ => data.notes = Some(block),
|
|
258
|
+
}
|
|
250
259
|
}
|
|
251
260
|
if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
|
|
252
261
|
validate_priority(Some(priority))?;
|
package/src/mcp/tools.rs
CHANGED
|
@@ -168,6 +168,23 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
168
168
|
"environment": {
|
|
169
169
|
"type": "object",
|
|
170
170
|
"description": "Free-form environment state"
|
|
171
|
+
},
|
|
172
|
+
"session_id": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"description": "Target active session ID. When multiple active sessions exist, specifies which to update/close. If omitted, uses the latest active session. Lower priority than close_session_id / pause_session_id."
|
|
175
|
+
},
|
|
176
|
+
"timeline": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "Session timeline/group label (e.g. 'feature-x', 'hotfix-y')."
|
|
179
|
+
},
|
|
180
|
+
"label": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": "Short human-readable session label for switching UI (e.g. 'WT2作業', 'API設計')."
|
|
183
|
+
},
|
|
184
|
+
"related_task_ids": {
|
|
185
|
+
"type": "array",
|
|
186
|
+
"description": "Task IDs this session is primarily working on.",
|
|
187
|
+
"items": { "type": "string" }
|
|
171
188
|
}
|
|
172
189
|
},
|
|
173
190
|
"required": ["summary"]
|
|
@@ -272,6 +289,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
272
289
|
"enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
|
|
273
290
|
},
|
|
274
291
|
"notes": { "type": "string" },
|
|
292
|
+
"notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
|
|
275
293
|
"priority": {
|
|
276
294
|
"type": "string",
|
|
277
295
|
"enum": ["low", "medium", "high"]
|
|
@@ -695,6 +713,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
695
713
|
"type": "string",
|
|
696
714
|
"description": "Project directory path. Defaults to current working directory."
|
|
697
715
|
},
|
|
716
|
+
"session_id": {
|
|
717
|
+
"type": "string",
|
|
718
|
+
"description": "Target active session ID. When multiple active sessions exist, specifies which to update. If omitted and multiple exist, uses the latest."
|
|
719
|
+
},
|
|
698
720
|
"checklist_index": {
|
|
699
721
|
"type": "integer",
|
|
700
722
|
"description": "0-based index of a checklist item to toggle."
|
|
@@ -798,9 +820,17 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
798
820
|
"enum": ["open", "active", "paused", "closed"],
|
|
799
821
|
"description": "Filter sessions by status."
|
|
800
822
|
},
|
|
823
|
+
"timeline": {
|
|
824
|
+
"type": "string",
|
|
825
|
+
"description": "Filter sessions by timeline label."
|
|
826
|
+
},
|
|
801
827
|
"limit": {
|
|
802
828
|
"type": "integer",
|
|
803
829
|
"description": "Max sessions to return (default 20)."
|
|
830
|
+
},
|
|
831
|
+
"include_children": {
|
|
832
|
+
"type": "boolean",
|
|
833
|
+
"description": "If true, include a 'children' array on each session showing its forked child sessions."
|
|
804
834
|
}
|
|
805
835
|
}
|
|
806
836
|
}),
|
|
@@ -838,6 +868,8 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
838
868
|
"status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
|
|
839
869
|
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
|
|
840
870
|
"assignee": { "type": "string" },
|
|
871
|
+
"notes": { "type": "string", "description": "Replace task notes." },
|
|
872
|
+
"notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
|
|
841
873
|
"schedule": {
|
|
842
874
|
"type": "object",
|
|
843
875
|
"properties": {
|
|
@@ -1082,6 +1114,74 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1082
1114
|
}
|
|
1083
1115
|
}),
|
|
1084
1116
|
},
|
|
1117
|
+
// ---- Session fork/merge tools ----
|
|
1118
|
+
ToolDefinition {
|
|
1119
|
+
name: "handoff_fork_session".to_string(),
|
|
1120
|
+
description: "Fork a new session from an existing one. Inherits decisions, context_pointers, references, and handoff_notes by default. The forked session becomes active with parent_session_id set.".to_string(),
|
|
1121
|
+
input_schema: json!({
|
|
1122
|
+
"type": "object",
|
|
1123
|
+
"properties": {
|
|
1124
|
+
"project_dir": {
|
|
1125
|
+
"type": "string",
|
|
1126
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
1127
|
+
},
|
|
1128
|
+
"source_session_id": {
|
|
1129
|
+
"type": "string",
|
|
1130
|
+
"description": "Session ID to fork from (active, paused, or closed)."
|
|
1131
|
+
},
|
|
1132
|
+
"summary": {
|
|
1133
|
+
"type": "string",
|
|
1134
|
+
"description": "Summary for the new forked session."
|
|
1135
|
+
},
|
|
1136
|
+
"label": {
|
|
1137
|
+
"type": "string",
|
|
1138
|
+
"description": "Short human-readable label for the forked session."
|
|
1139
|
+
},
|
|
1140
|
+
"timeline": {
|
|
1141
|
+
"type": "string",
|
|
1142
|
+
"description": "Timeline label. Defaults to the source session's timeline."
|
|
1143
|
+
},
|
|
1144
|
+
"inherit": {
|
|
1145
|
+
"type": "array",
|
|
1146
|
+
"description": "Fields to inherit from the source. Default: [\"decisions\", \"context_pointers\", \"references\", \"handoff_notes\", \"environment\"]. Available: decisions, context_pointers, references, handoff_notes, environment, blockers, checklist.",
|
|
1147
|
+
"items": { "type": "string" }
|
|
1148
|
+
},
|
|
1149
|
+
"related_task_ids": {
|
|
1150
|
+
"type": "array",
|
|
1151
|
+
"description": "Task IDs the forked session will work on.",
|
|
1152
|
+
"items": { "type": "string" }
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
"required": ["source_session_id", "summary"]
|
|
1156
|
+
}),
|
|
1157
|
+
},
|
|
1158
|
+
ToolDefinition {
|
|
1159
|
+
name: "handoff_merge_sessions".to_string(),
|
|
1160
|
+
description: "Merge multiple sessions into one. Combines decisions, notes, references, and context_pointers. Detects duplicate decisions as conflicts. Source sessions (except the target) are closed by default.".to_string(),
|
|
1161
|
+
input_schema: json!({
|
|
1162
|
+
"type": "object",
|
|
1163
|
+
"properties": {
|
|
1164
|
+
"project_dir": {
|
|
1165
|
+
"type": "string",
|
|
1166
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
1167
|
+
},
|
|
1168
|
+
"source_session_ids": {
|
|
1169
|
+
"type": "array",
|
|
1170
|
+
"description": "Session IDs to merge (must include at least 2).",
|
|
1171
|
+
"items": { "type": "string" }
|
|
1172
|
+
},
|
|
1173
|
+
"target_session_id": {
|
|
1174
|
+
"type": "string",
|
|
1175
|
+
"description": "Which source session becomes the merge target (must be one of source_session_ids)."
|
|
1176
|
+
},
|
|
1177
|
+
"close_sources": {
|
|
1178
|
+
"type": "boolean",
|
|
1179
|
+
"description": "Close non-target source sessions after merge. Default: true."
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
"required": ["source_session_ids", "target_session_id"]
|
|
1183
|
+
}),
|
|
1184
|
+
},
|
|
1085
1185
|
// ---- Timer coordination tools ----
|
|
1086
1186
|
ToolDefinition {
|
|
1087
1187
|
name: "handoff_timer_start".to_string(),
|
package/src/storage/config.rs
CHANGED
|
@@ -90,6 +90,9 @@ pub struct SettingsConfig {
|
|
|
90
90
|
/// Idle timeout in minutes for MCP fallback timer. Default 10.
|
|
91
91
|
#[serde(default = "default_timer_idle_timeout_minutes")]
|
|
92
92
|
pub timer_idle_timeout_minutes: u64,
|
|
93
|
+
/// Allow multiple active sessions simultaneously. Default false (single-active).
|
|
94
|
+
#[serde(default)]
|
|
95
|
+
pub multi_session: bool,
|
|
93
96
|
#[serde(default)]
|
|
94
97
|
pub custom_fields: HashMap<String, toml::Value>,
|
|
95
98
|
}
|
|
@@ -295,6 +298,7 @@ impl Default for SettingsConfig {
|
|
|
295
298
|
timer_provider: default_timer_provider(),
|
|
296
299
|
timer_authority_ttl_secs: default_timer_authority_ttl_secs(),
|
|
297
300
|
timer_idle_timeout_minutes: default_timer_idle_timeout_minutes(),
|
|
301
|
+
multi_session: false,
|
|
298
302
|
custom_fields: HashMap::new(),
|
|
299
303
|
}
|
|
300
304
|
}
|
|
@@ -311,6 +315,10 @@ impl Default for DashboardConfig {
|
|
|
311
315
|
|
|
312
316
|
impl Config {
|
|
313
317
|
pub fn new(name: &str, description: &str) -> Self {
|
|
318
|
+
let settings = SettingsConfig {
|
|
319
|
+
multi_session: true,
|
|
320
|
+
..SettingsConfig::default()
|
|
321
|
+
};
|
|
314
322
|
Self {
|
|
315
323
|
project: ProjectConfig {
|
|
316
324
|
name: name.to_string(),
|
|
@@ -320,7 +328,7 @@ impl Config {
|
|
|
320
328
|
Some(description.to_string())
|
|
321
329
|
},
|
|
322
330
|
},
|
|
323
|
-
settings
|
|
331
|
+
settings,
|
|
324
332
|
dashboard: DashboardConfig::default(),
|
|
325
333
|
started_at: None,
|
|
326
334
|
schedule_mode: None,
|
package/src/storage/sessions.rs
CHANGED
|
@@ -32,6 +32,14 @@ pub struct SessionData {
|
|
|
32
32
|
pub context_pointers: Vec<Value>,
|
|
33
33
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
34
34
|
pub environment: Option<Value>,
|
|
35
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
36
|
+
pub timeline: Option<String>,
|
|
37
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
38
|
+
pub label: Option<String>,
|
|
39
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
40
|
+
pub parent_session_id: Option<String>,
|
|
41
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
42
|
+
pub related_task_ids: Vec<String>,
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
pub fn generate_session_id() -> String {
|
|
@@ -169,13 +177,12 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
169
177
|
read_sessions_by_status(sessions_dir, "active")
|
|
170
178
|
}
|
|
171
179
|
|
|
172
|
-
/// Append `decision`
|
|
173
|
-
///
|
|
174
|
-
/// updated. Used by tools like auto_schedule to record applied changes so the
|
|
175
|
-
/// audit trail lives with the session, not just the tool response.
|
|
180
|
+
/// Append `decision` to active session(s). When `target_session_id` is Some,
|
|
181
|
+
/// only the matching session is updated; otherwise all active sessions are updated.
|
|
176
182
|
pub fn append_decision_to_active_sessions(
|
|
177
183
|
sessions_dir: &Path,
|
|
178
184
|
decision: serde_json::Value,
|
|
185
|
+
target_session_id: Option<&str>,
|
|
179
186
|
) -> Result<usize> {
|
|
180
187
|
let suffix = ".active.json";
|
|
181
188
|
if !sessions_dir.exists() {
|
|
@@ -194,6 +201,14 @@ pub fn append_decision_to_active_sessions(
|
|
|
194
201
|
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
195
202
|
let mut data: SessionData = serde_json::from_str(&content)
|
|
196
203
|
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
204
|
+
|
|
205
|
+
if let Some(tid) = target_session_id {
|
|
206
|
+
let file_id = data.id.as_deref().unwrap_or("");
|
|
207
|
+
if !ids_match(file_id, tid) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
197
212
|
data.decisions.push(decision.clone());
|
|
198
213
|
let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
199
214
|
crate::storage::atomic_write(&path, updated.as_bytes())
|
|
@@ -369,6 +384,15 @@ fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
|
|
|
369
384
|
if updates.environment.is_some() {
|
|
370
385
|
data.environment = updates.environment.clone();
|
|
371
386
|
}
|
|
387
|
+
if updates.timeline.is_some() {
|
|
388
|
+
data.timeline = updates.timeline.clone();
|
|
389
|
+
}
|
|
390
|
+
if updates.label.is_some() {
|
|
391
|
+
data.label = updates.label.clone();
|
|
392
|
+
}
|
|
393
|
+
if !updates.related_task_ids.is_empty() {
|
|
394
|
+
data.related_task_ids = updates.related_task_ids.clone();
|
|
395
|
+
}
|
|
372
396
|
}
|
|
373
397
|
|
|
374
398
|
fn find_and_update_active_session(
|
|
@@ -467,6 +491,277 @@ pub fn update_active_session(
|
|
|
467
491
|
find_and_update_active_session(sessions_dir, session_id, updates, None)
|
|
468
492
|
}
|
|
469
493
|
|
|
494
|
+
pub fn read_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<SessionData>> {
|
|
495
|
+
if !sessions_dir.exists() {
|
|
496
|
+
return Ok(None);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let mut matches: Vec<(SessionData, String)> = Vec::new();
|
|
500
|
+
|
|
501
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
502
|
+
let entry = entry?;
|
|
503
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
504
|
+
if !name.ends_with(".json") {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
let content = std::fs::read_to_string(entry.path())
|
|
508
|
+
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
509
|
+
let data: SessionData = serde_json::from_str(&content)
|
|
510
|
+
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
511
|
+
|
|
512
|
+
let file_id = data.id.as_deref().unwrap_or("");
|
|
513
|
+
let resolved_id = if file_id.is_empty() {
|
|
514
|
+
synthesize_id_from_filename(&name)
|
|
515
|
+
} else {
|
|
516
|
+
file_id.to_string()
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
if ids_match(&resolved_id, session_id) {
|
|
520
|
+
matches.push((data, resolved_id));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if matches.len() > 1 {
|
|
525
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
|
|
526
|
+
anyhow::bail!(
|
|
527
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
528
|
+
session_id,
|
|
529
|
+
matches.len(),
|
|
530
|
+
candidates.join(", ")
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
Ok(matches.into_iter().next().map(|(data, _)| data))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
pub fn fork_session(
|
|
538
|
+
sessions_dir: &Path,
|
|
539
|
+
source: &SessionData,
|
|
540
|
+
summary: &str,
|
|
541
|
+
label: Option<&str>,
|
|
542
|
+
timeline: Option<&str>,
|
|
543
|
+
related_task_ids: Vec<String>,
|
|
544
|
+
inherit: &[&str],
|
|
545
|
+
) -> Result<SessionData> {
|
|
546
|
+
let source_id = source
|
|
547
|
+
.id
|
|
548
|
+
.as_deref()
|
|
549
|
+
.ok_or_else(|| anyhow::anyhow!("Source session has no ID"))?;
|
|
550
|
+
|
|
551
|
+
let mut forked = SessionData {
|
|
552
|
+
version: source.version,
|
|
553
|
+
id: Some(generate_session_id()),
|
|
554
|
+
ended_at: None,
|
|
555
|
+
summary: summary.to_string(),
|
|
556
|
+
branch: None,
|
|
557
|
+
commit: None,
|
|
558
|
+
dirty_files: Vec::new(),
|
|
559
|
+
decisions: Vec::new(),
|
|
560
|
+
blockers: Vec::new(),
|
|
561
|
+
checklist: Vec::new(),
|
|
562
|
+
handoff_notes: Vec::new(),
|
|
563
|
+
references: Vec::new(),
|
|
564
|
+
context_pointers: Vec::new(),
|
|
565
|
+
environment: None,
|
|
566
|
+
timeline: timeline
|
|
567
|
+
.map(String::from)
|
|
568
|
+
.or_else(|| source.timeline.clone()),
|
|
569
|
+
label: label.map(String::from),
|
|
570
|
+
parent_session_id: Some(source_id.to_string()),
|
|
571
|
+
related_task_ids,
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
for field in inherit {
|
|
575
|
+
match *field {
|
|
576
|
+
"decisions" => forked.decisions = source.decisions.clone(),
|
|
577
|
+
"context_pointers" => forked.context_pointers = source.context_pointers.clone(),
|
|
578
|
+
"references" => forked.references = source.references.clone(),
|
|
579
|
+
"handoff_notes" => forked.handoff_notes = source.handoff_notes.clone(),
|
|
580
|
+
"environment" => forked.environment = source.environment.clone(),
|
|
581
|
+
"blockers" => forked.blockers = source.blockers.clone(),
|
|
582
|
+
"checklist" => forked.checklist = source.checklist.clone(),
|
|
583
|
+
_ => {}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
write_session_with_status(sessions_dir, &forked, "active")?;
|
|
588
|
+
Ok(forked)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
pub fn merge_sessions(
|
|
592
|
+
sessions_dir: &Path,
|
|
593
|
+
source_ids: &[&str],
|
|
594
|
+
target_id: &str,
|
|
595
|
+
close_sources: bool,
|
|
596
|
+
) -> Result<MergeResult> {
|
|
597
|
+
let mut sources: Vec<SessionData> = Vec::new();
|
|
598
|
+
for sid in source_ids {
|
|
599
|
+
let session = read_session_by_id(sessions_dir, sid)?
|
|
600
|
+
.ok_or_else(|| anyhow::anyhow!("Source session not found: {sid}"))?;
|
|
601
|
+
sources.push(session);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let mut target = read_session_by_id(sessions_dir, target_id)?
|
|
605
|
+
.ok_or_else(|| anyhow::anyhow!("Target session not found: {target_id}"))?;
|
|
606
|
+
|
|
607
|
+
let target_actual_id = target.id.clone().unwrap_or_default();
|
|
608
|
+
|
|
609
|
+
let mut merged_decisions = 0usize;
|
|
610
|
+
let mut merged_notes = 0usize;
|
|
611
|
+
let mut merged_references = 0usize;
|
|
612
|
+
let mut merged_context_pointers = 0usize;
|
|
613
|
+
let mut conflicts = Vec::new();
|
|
614
|
+
let mut closed_sessions = Vec::new();
|
|
615
|
+
|
|
616
|
+
for source in &sources {
|
|
617
|
+
let source_actual_id = source.id.as_deref().unwrap_or("");
|
|
618
|
+
if source_actual_id == target_actual_id {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for d in &source.decisions {
|
|
623
|
+
let decision_text = d.get("decision").and_then(|v| v.as_str()).unwrap_or("");
|
|
624
|
+
let already_exists = target.decisions.iter().any(|td| {
|
|
625
|
+
td.get("decision").and_then(|v| v.as_str()).unwrap_or("") == decision_text
|
|
626
|
+
});
|
|
627
|
+
if already_exists {
|
|
628
|
+
conflicts.push(MergeConflict {
|
|
629
|
+
conflict_type: "decision_conflict".to_string(),
|
|
630
|
+
description: format!("Duplicate decision: {decision_text}"),
|
|
631
|
+
session_a: target_actual_id.clone(),
|
|
632
|
+
session_b: source_actual_id.to_string(),
|
|
633
|
+
});
|
|
634
|
+
} else {
|
|
635
|
+
target.decisions.push(d.clone());
|
|
636
|
+
merged_decisions += 1;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for n in &source.handoff_notes {
|
|
641
|
+
target.handoff_notes.push(n.clone());
|
|
642
|
+
merged_notes += 1;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
for r in &source.references {
|
|
646
|
+
let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
|
647
|
+
let already_exists = target
|
|
648
|
+
.references
|
|
649
|
+
.iter()
|
|
650
|
+
.any(|tr| tr.get("label").and_then(|v| v.as_str()).unwrap_or("") == label);
|
|
651
|
+
if !already_exists {
|
|
652
|
+
target.references.push(r.clone());
|
|
653
|
+
merged_references += 1;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
for cp in &source.context_pointers {
|
|
658
|
+
let path = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
659
|
+
let already_exists = target
|
|
660
|
+
.context_pointers
|
|
661
|
+
.iter()
|
|
662
|
+
.any(|tcp| tcp.get("path").and_then(|v| v.as_str()).unwrap_or("") == path);
|
|
663
|
+
if !already_exists {
|
|
664
|
+
target.context_pointers.push(cp.clone());
|
|
665
|
+
merged_context_pointers += 1;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for task_id in &source.related_task_ids {
|
|
670
|
+
if !target.related_task_ids.contains(task_id) {
|
|
671
|
+
target.related_task_ids.push(task_id.clone());
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if close_sources {
|
|
676
|
+
if let Some(path) = close_session_by_id(sessions_dir, source_actual_id)? {
|
|
677
|
+
let _ = path;
|
|
678
|
+
closed_sessions.push(source_actual_id.to_string());
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
update_session_in_place(sessions_dir, &target_actual_id, &target)?;
|
|
684
|
+
|
|
685
|
+
Ok(MergeResult {
|
|
686
|
+
merged_session_id: target_actual_id,
|
|
687
|
+
merged_decisions,
|
|
688
|
+
merged_notes,
|
|
689
|
+
merged_references,
|
|
690
|
+
merged_context_pointers,
|
|
691
|
+
conflicts,
|
|
692
|
+
closed_sessions,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
fn update_session_in_place(
|
|
697
|
+
sessions_dir: &Path,
|
|
698
|
+
session_id: &str,
|
|
699
|
+
updated: &SessionData,
|
|
700
|
+
) -> Result<()> {
|
|
701
|
+
if !sessions_dir.exists() {
|
|
702
|
+
anyhow::bail!("Sessions directory not found");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let mut matches: Vec<(PathBuf, String)> = Vec::new();
|
|
706
|
+
|
|
707
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
708
|
+
let entry = entry?;
|
|
709
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
710
|
+
if !name.ends_with(".json") {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
let content = std::fs::read_to_string(entry.path())?;
|
|
714
|
+
let data: SessionData = serde_json::from_str(&content)?;
|
|
715
|
+
let file_id = data.id.as_deref().unwrap_or("");
|
|
716
|
+
let resolved_id = if file_id.is_empty() {
|
|
717
|
+
synthesize_id_from_filename(&name)
|
|
718
|
+
} else {
|
|
719
|
+
file_id.to_string()
|
|
720
|
+
};
|
|
721
|
+
if ids_match(&resolved_id, session_id) {
|
|
722
|
+
matches.push((entry.path(), resolved_id));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if matches.len() > 1 {
|
|
727
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
|
|
728
|
+
anyhow::bail!(
|
|
729
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
730
|
+
session_id,
|
|
731
|
+
matches.len(),
|
|
732
|
+
candidates.join(", ")
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if let Some((path, _)) = matches.into_iter().next() {
|
|
737
|
+
let serialized =
|
|
738
|
+
serde_json::to_string_pretty(updated).context("Failed to serialize session")?;
|
|
739
|
+
crate::storage::atomic_write(path, serialized.as_bytes())?;
|
|
740
|
+
return Ok(());
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
anyhow::bail!("Session not found for in-place update: {session_id}")
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#[derive(Debug)]
|
|
747
|
+
pub struct MergeConflict {
|
|
748
|
+
pub conflict_type: String,
|
|
749
|
+
pub description: String,
|
|
750
|
+
pub session_a: String,
|
|
751
|
+
pub session_b: String,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
#[derive(Debug)]
|
|
755
|
+
pub struct MergeResult {
|
|
756
|
+
pub merged_session_id: String,
|
|
757
|
+
pub merged_decisions: usize,
|
|
758
|
+
pub merged_notes: usize,
|
|
759
|
+
pub merged_references: usize,
|
|
760
|
+
pub merged_context_pointers: usize,
|
|
761
|
+
pub conflicts: Vec<MergeConflict>,
|
|
762
|
+
pub closed_sessions: Vec<String>,
|
|
763
|
+
}
|
|
764
|
+
|
|
470
765
|
pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
471
766
|
if !sessions_dir.exists() {
|
|
472
767
|
return Ok(0);
|