handoff-mcp-server 0.15.1 → 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 +56 -11
- package/package.json +1 -1
- package/scripts/sync-plugin-skills.sh +13 -0
- 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
|
@@ -33,7 +33,37 @@ At session start, the agent calls `handoff_load_context` to pick up where things
|
|
|
33
33
|
|
|
34
34
|
## Installation
|
|
35
35
|
|
|
36
|
-
###
|
|
36
|
+
### Claude Code Plugin (recommended)
|
|
37
|
+
|
|
38
|
+
The easiest way to install handoff-mcp is as a Claude Code plugin:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. Install the binary (required — the plugin calls it)
|
|
42
|
+
npm install -g handoff-mcp-server
|
|
43
|
+
# or: cargo install handoff-mcp
|
|
44
|
+
|
|
45
|
+
# 2. Add the marketplace
|
|
46
|
+
/plugin marketplace add alphaelements/handoff-mcp
|
|
47
|
+
|
|
48
|
+
# 3. Install the plugin (MCP server + skills)
|
|
49
|
+
/plugin install handoff-mcp@handoff-mcp-marketplace
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This registers the MCP server and all skills automatically — no manual
|
|
53
|
+
`.mcp.json` or skill file setup needed.
|
|
54
|
+
|
|
55
|
+
**Optional: memory auto-injection hooks**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
/plugin install handoff-mcp-hooks@handoff-mcp-marketplace
|
|
59
|
+
/plugin enable handoff-mcp-hooks
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This adds hooks that inject relevant project memories on every prompt and file
|
|
63
|
+
edit. Disable anytime with `/plugin disable handoff-mcp-hooks` — the MCP server
|
|
64
|
+
and skills remain active.
|
|
65
|
+
|
|
66
|
+
### cargo
|
|
37
67
|
|
|
38
68
|
```bash
|
|
39
69
|
cargo install handoff-mcp
|
|
@@ -58,9 +88,10 @@ cd handoff-mcp
|
|
|
58
88
|
cargo build --release
|
|
59
89
|
```
|
|
60
90
|
|
|
61
|
-
## Setup
|
|
91
|
+
## Setup (non-plugin)
|
|
62
92
|
|
|
63
|
-
|
|
93
|
+
If you installed via cargo/npm (without the plugin), register handoff-mcp as an
|
|
94
|
+
MCP server in Claude Code manually:
|
|
64
95
|
|
|
65
96
|
**Option A** — CLI (recommended):
|
|
66
97
|
|
|
@@ -86,8 +117,9 @@ The `-s user` flag registers it globally (available in all projects). Verify wit
|
|
|
86
117
|
|
|
87
118
|
### Enable automatic memory injection (optional)
|
|
88
119
|
|
|
89
|
-
If you
|
|
90
|
-
|
|
120
|
+
If you installed via the plugin, use `handoff-mcp-hooks` instead (see above).
|
|
121
|
+
|
|
122
|
+
For non-plugin installs, run:
|
|
91
123
|
|
|
92
124
|
```bash
|
|
93
125
|
handoff-mcp setup
|
|
@@ -145,6 +177,8 @@ manual configuration alternative.
|
|
|
145
177
|
| `handoff_update_session` | Incrementally update active session (toggle checklist, add decisions/notes/pointers) |
|
|
146
178
|
| `handoff_list_sessions` | List all sessions (open/active/paused/closed) with summary info |
|
|
147
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 |
|
|
148
182
|
|
|
149
183
|
### Task Management
|
|
150
184
|
|
|
@@ -152,7 +186,7 @@ manual configuration alternative.
|
|
|
152
186
|
|------|---------|
|
|
153
187
|
| `handoff_list_tasks` | List tasks with filters (status, assignee, milestone, priority, label) |
|
|
154
188
|
| `handoff_get_task` | Get full task details (notes, done_criteria, schedule, etc.) |
|
|
155
|
-
| `handoff_update_task` | Create, update, or move tasks
|
|
189
|
+
| `handoff_update_task` | Create, update, or move tasks; supports `notes_append` for safe incremental notes |
|
|
156
190
|
| `handoff_check_criterion` | Toggle a single done_criteria item by index |
|
|
157
191
|
| `handoff_log_time` | Log hours worked — adds to `actual_hours`, deducts from `remaining_hours` |
|
|
158
192
|
| `handoff_bulk_update_tasks` | Update multiple tasks in one call (status, schedule, assignee, priority) |
|
|
@@ -287,6 +321,7 @@ history_limit = 20 # Max closed sessions to keep
|
|
|
287
321
|
done_task_limit = 10 # Max completed tasks to show
|
|
288
322
|
auto_git_summary = true # Capture git state automatically
|
|
289
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)
|
|
290
325
|
ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
|
|
291
326
|
timer_provider = "auto" # "auto" | "vscode" | "mcp" | "off"
|
|
292
327
|
timer_authority_ttl_secs = 30 # Heartbeat freshness TTL for authority.json
|
|
@@ -550,16 +585,26 @@ This project uses handoff-mcp for session continuity.
|
|
|
550
585
|
surfaced as conflicts for you to merge or force-save — never merged silently.
|
|
551
586
|
```
|
|
552
587
|
|
|
553
|
-
##
|
|
588
|
+
## Skills
|
|
554
589
|
|
|
555
|
-
This repository includes
|
|
590
|
+
This repository includes skill files that make handoff behavior automatic in Claude Code:
|
|
591
|
+
|
|
592
|
+
| Skill | Purpose |
|
|
593
|
+
|-------|---------|
|
|
594
|
+
| `handoff` | Core session lifecycle, task management, metrics, scheduling |
|
|
595
|
+
| `handoff-load` | Quick session-start procedure |
|
|
596
|
+
| `handoff-memory` | Memory CRUD, conflict handling, cleanup |
|
|
597
|
+
| `handoff-refer` | Cross-project referrals |
|
|
598
|
+
| `handoff-import` | Bulk import from documents |
|
|
599
|
+
|
|
600
|
+
**Plugin users**: all skills are included automatically.
|
|
601
|
+
|
|
602
|
+
**Manual setup**: copy the skills to your user skills directory:
|
|
556
603
|
|
|
557
604
|
```bash
|
|
558
|
-
cp -r skills
|
|
605
|
+
cp -r skills/* ~/.claude/skills/
|
|
559
606
|
```
|
|
560
607
|
|
|
561
|
-
This teaches the agent to automatically load context at session start, track tasks during work, and save context at session end.
|
|
562
|
-
|
|
563
608
|
## Compatibility
|
|
564
609
|
|
|
565
610
|
- **Claude Code** — fully supported (stdio transport)
|
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>",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sync skills/ -> plugin/skills/ for plugin distribution.
|
|
3
|
+
# Run before release to ensure the plugin has the latest skills.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
ROOT="$(dirname "$SCRIPT_DIR")"
|
|
8
|
+
|
|
9
|
+
rm -rf "$ROOT/plugin/skills"
|
|
10
|
+
cp -a "$ROOT/skills" "$ROOT/plugin/skills"
|
|
11
|
+
|
|
12
|
+
echo "Synced skills/ -> plugin/skills/"
|
|
13
|
+
diff -rq "$ROOT/skills/" "$ROOT/plugin/skills/" && echo "No differences." || echo "WARNING: differences remain after sync."
|
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
|
+
}
|