handoff-mcp-server 0.13.0 → 0.15.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 +25 -229
- package/Cargo.toml +2 -2
- package/README.md +116 -18
- package/package.json +1 -1
- package/scripts/handoff-memory-hook.py +6 -6
- package/skills/handoff/SKILL.md +17 -0
- package/src/cli.rs +551 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +57 -0
- package/src/mcp/handlers/bulk_update.rs +1 -1
- package/src/mcp/handlers/check_criterion.rs +5 -6
- package/src/mcp/handlers/config.rs +38 -0
- package/src/mcp/handlers/get_task.rs +3 -6
- package/src/mcp/handlers/log_time.rs +3 -6
- package/src/mcp/handlers/mod.rs +8 -4
- package/src/mcp/handlers/timer.rs +529 -0
- package/src/mcp/handlers/update_task.rs +10 -21
- package/src/mcp/tools.rs +41 -4
- package/src/setup.rs +424 -0
- package/src/storage/config.rs +25 -0
- package/src/storage/tasks.rs +95 -4
|
@@ -69,7 +69,7 @@ fn handle_create(
|
|
|
69
69
|
let (new_id, parent_dir) = match parent_id {
|
|
70
70
|
Some(pid) => {
|
|
71
71
|
let parent_dir = find_task_dir_by_id(tasks_dir, pid)?
|
|
72
|
-
.ok_or_else(|| anyhow::anyhow!("
|
|
72
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?;
|
|
73
73
|
let id = next_child_id(&parent_dir, pid)?;
|
|
74
74
|
(id, parent_dir)
|
|
75
75
|
}
|
|
@@ -154,20 +154,15 @@ fn handle_upsert_create(
|
|
|
154
154
|
.get("title")
|
|
155
155
|
.and_then(|v| v.as_str())
|
|
156
156
|
.ok_or_else(|| {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
Provide 'title' to create a new task with this ID, or use handoff_list_tasks to find existing task IDs."
|
|
160
|
-
)
|
|
157
|
+
let hint = suggest_task_id(tasks_dir, task_id);
|
|
158
|
+
anyhow::anyhow!("{hint}\nProvide 'title' to create a new task with this ID.")
|
|
161
159
|
})?;
|
|
162
160
|
|
|
163
161
|
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
164
162
|
|
|
165
163
|
let parent_dir = match parent_id {
|
|
166
|
-
Some(pid) => find_task_dir_by_id(tasks_dir, pid)
|
|
167
|
-
anyhow::anyhow!(
|
|
168
|
-
"Parent task not found: {pid}. Use handoff_list_tasks to see available task IDs."
|
|
169
|
-
)
|
|
170
|
-
})?,
|
|
164
|
+
Some(pid) => find_task_dir_by_id(tasks_dir, pid)?
|
|
165
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?,
|
|
171
166
|
None => tasks_dir.to_path_buf(),
|
|
172
167
|
};
|
|
173
168
|
|
|
@@ -242,7 +237,7 @@ fn handle_update(
|
|
|
242
237
|
require_estimate_hours: bool,
|
|
243
238
|
) -> Result<String> {
|
|
244
239
|
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
245
|
-
.ok_or_else(|| anyhow::anyhow!("
|
|
240
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
246
241
|
|
|
247
242
|
let (mut data, current_status) = read_task(&task_dir)?
|
|
248
243
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -352,17 +347,11 @@ fn handle_update(
|
|
|
352
347
|
}
|
|
353
348
|
|
|
354
349
|
fn handle_move(tasks_dir: &std::path::Path, task_id: &str, new_parent_id: &str) -> Result<String> {
|
|
355
|
-
let task_dir = find_task_dir_by_id(tasks_dir, task_id)
|
|
356
|
-
anyhow::anyhow!(
|
|
357
|
-
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
358
|
-
)
|
|
359
|
-
})?;
|
|
350
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
351
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
360
352
|
|
|
361
|
-
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)
|
|
362
|
-
anyhow::anyhow!(
|
|
363
|
-
"New parent task not found: {new_parent_id}. Use handoff_list_tasks to see available task IDs."
|
|
364
|
-
)
|
|
365
|
-
})?;
|
|
353
|
+
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)?
|
|
354
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, new_parent_id)))?;
|
|
366
355
|
|
|
367
356
|
let dir_name = task_dir
|
|
368
357
|
.file_name()
|
package/src/mcp/tools.rs
CHANGED
|
@@ -1024,7 +1024,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1024
1024
|
}),
|
|
1025
1025
|
},
|
|
1026
1026
|
ToolDefinition {
|
|
1027
|
-
name: "
|
|
1027
|
+
name: "handoff_memory_save".to_string(),
|
|
1028
1028
|
description: "Save a long-lived project memory (lesson/rule/convention/gotcha) that future sessions should respect. Detects exact and near-duplicate memories: an exact match is reported (not rewritten), a near-duplicate is returned as a 'conflict' with both bodies for you to merge (call again with merge_into=<id> and absorb_ids=[…]) or save separately with force=true. Returns a JSON string.".to_string(),
|
|
1029
1029
|
input_schema: json!({
|
|
1030
1030
|
"type": "object",
|
|
@@ -1042,7 +1042,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1042
1042
|
}),
|
|
1043
1043
|
},
|
|
1044
1044
|
ToolDefinition {
|
|
1045
|
-
name: "
|
|
1045
|
+
name: "handoff_memory_query".to_string(),
|
|
1046
1046
|
description: "Return the project memories most relevant to the given text/file (BM25 + scope-path boosting). Intended for automatic injection via hooks, but callable directly. Returns a JSON string {\"memories\":[{id,text,kind,score}],\"injected_count\"}.".to_string(),
|
|
1047
1047
|
input_schema: json!({
|
|
1048
1048
|
"type": "object",
|
|
@@ -1059,7 +1059,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1059
1059
|
}),
|
|
1060
1060
|
},
|
|
1061
1061
|
ToolDefinition {
|
|
1062
|
-
name: "
|
|
1062
|
+
name: "handoff_memory_delete".to_string(),
|
|
1063
1063
|
description: "Delete a project memory by id (full id or unique prefix). Use for AI-driven cleanup of stale memories. Returns a JSON string {\"status\":\"deleted\",\"id\"}.".to_string(),
|
|
1064
1064
|
input_schema: json!({
|
|
1065
1065
|
"type": "object",
|
|
@@ -1071,7 +1071,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1071
1071
|
}),
|
|
1072
1072
|
},
|
|
1073
1073
|
ToolDefinition {
|
|
1074
|
-
name: "
|
|
1074
|
+
name: "handoff_memory_cleanup".to_string(),
|
|
1075
1075
|
description: "Housekeep the project memory store (intended for SessionStart). Silently merges exact duplicates (lossless), then returns recommendations the AI should act on: near-duplicate clusters (merge with memory_save merge_into=…) and stale memories (consider memory_delete). Also garbage-collects old per-session injection sidecars. Returns a JSON string {\"auto_merged_exact\":n,\"cleanup_recommendations\":{\"similar_clusters\":[…],\"stale\":[…]},\"injected_sidecars_removed\":k}.".to_string(),
|
|
1076
1076
|
input_schema: json!({
|
|
1077
1077
|
"type": "object",
|
|
@@ -1082,6 +1082,43 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1082
1082
|
}
|
|
1083
1083
|
}),
|
|
1084
1084
|
},
|
|
1085
|
+
// ---- Timer coordination tools ----
|
|
1086
|
+
ToolDefinition {
|
|
1087
|
+
name: "handoff_timer_start".to_string(),
|
|
1088
|
+
description: "Start a timer for a task. If VSCode extension is running (authority alive), delegates to the extension via a request file. Otherwise starts an MCP fallback timer.".to_string(),
|
|
1089
|
+
input_schema: json!({
|
|
1090
|
+
"type": "object",
|
|
1091
|
+
"properties": {
|
|
1092
|
+
"task_id": { "type": "string", "description": "Task ID to start timing (e.g. 't1', 't1.2')." },
|
|
1093
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." }
|
|
1094
|
+
},
|
|
1095
|
+
"required": ["task_id"]
|
|
1096
|
+
}),
|
|
1097
|
+
},
|
|
1098
|
+
ToolDefinition {
|
|
1099
|
+
name: "handoff_timer_stop".to_string(),
|
|
1100
|
+
description: "Stop the timer for a task. If VSCode extension is the authority, delegates the stop command. If MCP is the authority (fallback), stops the internal timer and adds elapsed time to the task's actual_hours (with optimistic locking).".to_string(),
|
|
1101
|
+
input_schema: json!({
|
|
1102
|
+
"type": "object",
|
|
1103
|
+
"properties": {
|
|
1104
|
+
"task_id": { "type": "string", "description": "Task ID to stop timing." },
|
|
1105
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." }
|
|
1106
|
+
},
|
|
1107
|
+
"required": ["task_id"]
|
|
1108
|
+
}),
|
|
1109
|
+
},
|
|
1110
|
+
ToolDefinition {
|
|
1111
|
+
name: "handoff_timer_get_time".to_string(),
|
|
1112
|
+
description: "Get the current timer state for a task. Returns elapsed time, timer state (tracking/paused/stopped), authority info, and projected total hours. Reads from .handoff/timer/state.json.".to_string(),
|
|
1113
|
+
input_schema: json!({
|
|
1114
|
+
"type": "object",
|
|
1115
|
+
"properties": {
|
|
1116
|
+
"task_id": { "type": "string", "description": "Task ID to query timer for." },
|
|
1117
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." }
|
|
1118
|
+
},
|
|
1119
|
+
"required": ["task_id"]
|
|
1120
|
+
}),
|
|
1121
|
+
},
|
|
1085
1122
|
]
|
|
1086
1123
|
}
|
|
1087
1124
|
|
package/src/setup.rs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
const HOOK_SERVER: &str = "handoff";
|
|
8
|
+
const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
|
|
9
|
+
const HOOK_TOOL_CLEANUP: &str = "handoff_memory_cleanup";
|
|
10
|
+
|
|
11
|
+
fn settings_path() -> Result<PathBuf> {
|
|
12
|
+
let home = std::env::var("HOME")
|
|
13
|
+
.or_else(|_| std::env::var("USERPROFILE"))
|
|
14
|
+
.context("cannot determine home directory (HOME / USERPROFILE not set)")?;
|
|
15
|
+
Ok(Path::new(&home).join(".claude").join("settings.json"))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fn read_settings(path: &Path) -> Result<Value> {
|
|
19
|
+
if path.exists() {
|
|
20
|
+
let text = std::fs::read_to_string(path)
|
|
21
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
22
|
+
serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
|
|
23
|
+
} else {
|
|
24
|
+
Ok(Value::Object(serde_json::Map::new()))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn write_settings(path: &Path, value: &Value) -> Result<()> {
|
|
29
|
+
let parent = path
|
|
30
|
+
.parent()
|
|
31
|
+
.context("settings path has no parent directory")?;
|
|
32
|
+
std::fs::create_dir_all(parent)
|
|
33
|
+
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
34
|
+
|
|
35
|
+
let text = serde_json::to_string_pretty(value)?;
|
|
36
|
+
let tmp = parent.join(".settings.json.tmp");
|
|
37
|
+
std::fs::write(&tmp, text + "\n")
|
|
38
|
+
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
|
39
|
+
std::fs::rename(&tmp, path)
|
|
40
|
+
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn mcp_tool_hook(tool: &str, input: Value) -> Value {
|
|
44
|
+
serde_json::json!({
|
|
45
|
+
"type": "mcp_tool",
|
|
46
|
+
"server": HOOK_SERVER,
|
|
47
|
+
"tool": tool,
|
|
48
|
+
"input": input
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn build_hooks_config() -> BTreeMap<&'static str, Value> {
|
|
53
|
+
let mut hooks = BTreeMap::new();
|
|
54
|
+
|
|
55
|
+
hooks.insert(
|
|
56
|
+
"UserPromptSubmit",
|
|
57
|
+
serde_json::json!([{
|
|
58
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
|
|
59
|
+
"project_dir": "${cwd}",
|
|
60
|
+
"session_id": "${session_id}",
|
|
61
|
+
"text": "${prompt}"
|
|
62
|
+
}))]
|
|
63
|
+
}]),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
hooks.insert(
|
|
67
|
+
"PreToolUse",
|
|
68
|
+
serde_json::json!([{
|
|
69
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
70
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
|
|
71
|
+
"project_dir": "${cwd}",
|
|
72
|
+
"session_id": "${session_id}",
|
|
73
|
+
"tool_name": "${tool_name}",
|
|
74
|
+
"text": "${tool_input.file_path}",
|
|
75
|
+
"file_paths": ["${tool_input.file_path}"]
|
|
76
|
+
}))]
|
|
77
|
+
}]),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
hooks.insert(
|
|
81
|
+
"SessionStart",
|
|
82
|
+
serde_json::json!([{
|
|
83
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_CLEANUP, serde_json::json!({
|
|
84
|
+
"project_dir": "${cwd}"
|
|
85
|
+
}))]
|
|
86
|
+
}]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
hooks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn has_handoff_hook(arr: &Value) -> bool {
|
|
93
|
+
let Some(entries) = arr.as_array() else {
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
for entry in entries {
|
|
97
|
+
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
98
|
+
continue;
|
|
99
|
+
};
|
|
100
|
+
for hook in hooks {
|
|
101
|
+
if hook.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
|
|
110
|
+
anyhow::ensure!(
|
|
111
|
+
!(check_only && uninstall),
|
|
112
|
+
"--check and --uninstall cannot be used together"
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
let path = settings_path()?;
|
|
116
|
+
let mut settings = read_settings(&path)?;
|
|
117
|
+
|
|
118
|
+
if check_only {
|
|
119
|
+
return run_check(&settings, &path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if uninstall {
|
|
123
|
+
return run_uninstall(&mut settings, &path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
run_install(&mut settings, &path)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn run_check(settings: &Value, path: &Path) -> Result<()> {
|
|
130
|
+
println!("Settings file: {}", path.display());
|
|
131
|
+
|
|
132
|
+
let hooks_obj = settings.get("hooks");
|
|
133
|
+
let desired = build_hooks_config();
|
|
134
|
+
let mut all_ok = true;
|
|
135
|
+
|
|
136
|
+
for event in desired.keys() {
|
|
137
|
+
let installed = hooks_obj
|
|
138
|
+
.and_then(|h| h.get(*event))
|
|
139
|
+
.map(has_handoff_hook)
|
|
140
|
+
.unwrap_or(false);
|
|
141
|
+
|
|
142
|
+
if installed {
|
|
143
|
+
println!(" {event}: installed");
|
|
144
|
+
} else {
|
|
145
|
+
println!(" {event}: NOT installed");
|
|
146
|
+
all_ok = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if all_ok {
|
|
151
|
+
println!("\nAll hooks are configured. Memory auto-injection is active.");
|
|
152
|
+
} else {
|
|
153
|
+
println!("\nSome hooks are missing. Run `handoff-mcp setup` to install them.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
Ok(())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
|
|
160
|
+
let obj = settings
|
|
161
|
+
.as_object_mut()
|
|
162
|
+
.context("settings.json root is not an object")?;
|
|
163
|
+
|
|
164
|
+
let hooks_val = obj
|
|
165
|
+
.entry("hooks")
|
|
166
|
+
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
|
167
|
+
let hooks_obj = hooks_val
|
|
168
|
+
.as_object_mut()
|
|
169
|
+
.context("settings.json 'hooks' is not an object")?;
|
|
170
|
+
|
|
171
|
+
let desired = build_hooks_config();
|
|
172
|
+
let mut installed = 0u32;
|
|
173
|
+
let mut skipped = 0u32;
|
|
174
|
+
|
|
175
|
+
for (event, config) in desired {
|
|
176
|
+
let existing = hooks_obj.get(event);
|
|
177
|
+
if existing.map(has_handoff_hook).unwrap_or(false) {
|
|
178
|
+
println!(" {event}: already installed, skipping");
|
|
179
|
+
skipped += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
match existing {
|
|
184
|
+
Some(Value::Array(arr)) => {
|
|
185
|
+
let mut merged = arr.clone();
|
|
186
|
+
if let Some(new_entries) = config.as_array() {
|
|
187
|
+
merged.extend(new_entries.iter().cloned());
|
|
188
|
+
}
|
|
189
|
+
hooks_obj.insert(event.to_string(), Value::Array(merged));
|
|
190
|
+
println!(" {event}: merged with existing hooks");
|
|
191
|
+
}
|
|
192
|
+
_ => {
|
|
193
|
+
hooks_obj.insert(event.to_string(), config);
|
|
194
|
+
println!(" {event}: installed");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
installed += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if installed > 0 {
|
|
201
|
+
write_settings(path, settings)?;
|
|
202
|
+
println!("\nWrote {path}", path = path.display());
|
|
203
|
+
println!("{installed} hook(s) installed, {skipped} already present.");
|
|
204
|
+
println!("\nRestart Claude Code for hooks to take effect.");
|
|
205
|
+
} else {
|
|
206
|
+
println!("\nAll hooks already installed. Nothing to do.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Ok(())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn run_uninstall(settings: &mut Value, path: &Path) -> Result<()> {
|
|
213
|
+
let Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
|
|
214
|
+
println!("No hooks configured. Nothing to remove.");
|
|
215
|
+
return Ok(());
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let events: Vec<String> = hooks_obj.keys().cloned().collect();
|
|
219
|
+
let mut removed = 0u32;
|
|
220
|
+
|
|
221
|
+
for event in &events {
|
|
222
|
+
let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
|
|
223
|
+
continue;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let before = arr.len();
|
|
227
|
+
arr.retain(|entry| {
|
|
228
|
+
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
!hooks
|
|
232
|
+
.iter()
|
|
233
|
+
.any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
let after = arr.len();
|
|
237
|
+
if before != after {
|
|
238
|
+
println!(" {event}: removed handoff hook(s)");
|
|
239
|
+
removed += 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if arr.is_empty() {
|
|
243
|
+
hooks_obj.remove(event);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if hooks_obj.is_empty() {
|
|
248
|
+
if let Some(obj) = settings.as_object_mut() {
|
|
249
|
+
obj.remove("hooks");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if removed > 0 {
|
|
254
|
+
write_settings(path, settings)?;
|
|
255
|
+
println!("\nWrote {path}", path = path.display());
|
|
256
|
+
println!("{removed} hook event(s) cleaned up.");
|
|
257
|
+
println!("\nRestart Claude Code for changes to take effect.");
|
|
258
|
+
} else {
|
|
259
|
+
println!("No handoff hooks found. Nothing to remove.");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
Ok(())
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[cfg(test)]
|
|
266
|
+
mod tests {
|
|
267
|
+
use super::*;
|
|
268
|
+
|
|
269
|
+
#[test]
|
|
270
|
+
fn build_hooks_has_three_events() {
|
|
271
|
+
let hooks = build_hooks_config();
|
|
272
|
+
assert_eq!(hooks.len(), 3);
|
|
273
|
+
assert!(hooks.contains_key("UserPromptSubmit"));
|
|
274
|
+
assert!(hooks.contains_key("PreToolUse"));
|
|
275
|
+
assert!(hooks.contains_key("SessionStart"));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn has_handoff_hook_detects_presence() {
|
|
280
|
+
let arr = serde_json::json!([{
|
|
281
|
+
"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
|
|
282
|
+
}]);
|
|
283
|
+
assert!(has_handoff_hook(&arr));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn has_handoff_hook_returns_false_for_other_server() {
|
|
288
|
+
let arr = serde_json::json!([{
|
|
289
|
+
"hooks": [{"type": "mcp_tool", "server": "other", "tool": "other_tool"}]
|
|
290
|
+
}]);
|
|
291
|
+
assert!(!has_handoff_hook(&arr));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#[test]
|
|
295
|
+
fn has_handoff_hook_returns_false_for_empty() {
|
|
296
|
+
assert!(!has_handoff_hook(&serde_json::json!([])));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn install_into_empty_settings() {
|
|
301
|
+
let dir = tempfile::tempdir().unwrap();
|
|
302
|
+
let path = dir.path().join("settings.json");
|
|
303
|
+
|
|
304
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
305
|
+
run_install(&mut settings, &path).unwrap();
|
|
306
|
+
|
|
307
|
+
let written: Value =
|
|
308
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
309
|
+
let hooks = written.get("hooks").unwrap().as_object().unwrap();
|
|
310
|
+
assert!(hooks.contains_key("UserPromptSubmit"));
|
|
311
|
+
assert!(hooks.contains_key("PreToolUse"));
|
|
312
|
+
assert!(hooks.contains_key("SessionStart"));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn install_merges_with_existing_hooks() {
|
|
317
|
+
let dir = tempfile::tempdir().unwrap();
|
|
318
|
+
let path = dir.path().join("settings.json");
|
|
319
|
+
|
|
320
|
+
let mut settings = serde_json::json!({
|
|
321
|
+
"hooks": {
|
|
322
|
+
"UserPromptSubmit": [{
|
|
323
|
+
"hooks": [{"type": "command", "command": "my-other-hook"}]
|
|
324
|
+
}]
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
run_install(&mut settings, &path).unwrap();
|
|
329
|
+
|
|
330
|
+
let written: Value =
|
|
331
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
332
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
333
|
+
assert_eq!(user_prompt.len(), 2);
|
|
334
|
+
assert!(has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn install_skips_if_already_present() {
|
|
339
|
+
let dir = tempfile::tempdir().unwrap();
|
|
340
|
+
let path = dir.path().join("settings.json");
|
|
341
|
+
|
|
342
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
343
|
+
run_install(&mut settings, &path).unwrap();
|
|
344
|
+
|
|
345
|
+
let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
346
|
+
run_install(&mut settings2, &path).unwrap();
|
|
347
|
+
|
|
348
|
+
let written: Value =
|
|
349
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
350
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
351
|
+
assert_eq!(user_prompt.len(), 1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[test]
|
|
355
|
+
fn uninstall_removes_handoff_hooks() {
|
|
356
|
+
let dir = tempfile::tempdir().unwrap();
|
|
357
|
+
let path = dir.path().join("settings.json");
|
|
358
|
+
|
|
359
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
360
|
+
run_install(&mut settings, &path).unwrap();
|
|
361
|
+
|
|
362
|
+
let mut settings2: Value =
|
|
363
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
364
|
+
run_uninstall(&mut settings2, &path).unwrap();
|
|
365
|
+
|
|
366
|
+
let written: Value =
|
|
367
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
368
|
+
assert!(written.get("hooks").is_none());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn check_and_uninstall_conflict_is_rejected() {
|
|
373
|
+
assert!(run_setup(true, true).is_err());
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn install_preserves_key_order() {
|
|
378
|
+
let dir = tempfile::tempdir().unwrap();
|
|
379
|
+
let path = dir.path().join("settings.json");
|
|
380
|
+
|
|
381
|
+
let original = r#"{
|
|
382
|
+
"env": {"A": "1", "B": "2"},
|
|
383
|
+
"model": "opus",
|
|
384
|
+
"permissions": {"defaultMode": "auto"}
|
|
385
|
+
}
|
|
386
|
+
"#;
|
|
387
|
+
std::fs::write(&path, original).unwrap();
|
|
388
|
+
|
|
389
|
+
let mut settings: Value = serde_json::from_str(original).unwrap();
|
|
390
|
+
run_install(&mut settings, &path).unwrap();
|
|
391
|
+
|
|
392
|
+
let written = std::fs::read_to_string(&path).unwrap();
|
|
393
|
+
let env_pos = written.find("\"env\"").unwrap();
|
|
394
|
+
let hooks_pos = written.find("\"hooks\"").unwrap();
|
|
395
|
+
let model_pos = written.find("\"model\"").unwrap();
|
|
396
|
+
assert!(env_pos < model_pos, "env should come before model");
|
|
397
|
+
assert!(
|
|
398
|
+
hooks_pos > env_pos,
|
|
399
|
+
"hooks should be appended after existing keys"
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[test]
|
|
404
|
+
fn uninstall_preserves_other_hooks() {
|
|
405
|
+
let dir = tempfile::tempdir().unwrap();
|
|
406
|
+
let path = dir.path().join("settings.json");
|
|
407
|
+
|
|
408
|
+
let mut settings = serde_json::json!({
|
|
409
|
+
"hooks": {
|
|
410
|
+
"UserPromptSubmit": [
|
|
411
|
+
{"hooks": [{"type": "command", "command": "my-hook"}]},
|
|
412
|
+
{"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]}
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
run_uninstall(&mut settings, &path).unwrap();
|
|
417
|
+
|
|
418
|
+
let written: Value =
|
|
419
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
420
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
421
|
+
assert_eq!(user_prompt.len(), 1);
|
|
422
|
+
assert!(!has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
|
|
423
|
+
}
|
|
424
|
+
}
|
package/src/storage/config.rs
CHANGED
|
@@ -80,6 +80,16 @@ pub struct SettingsConfig {
|
|
|
80
80
|
/// `injected/` sidecar. Default 14.
|
|
81
81
|
#[serde(default = "default_memory_injected_gc_days")]
|
|
82
82
|
pub memory_injected_gc_days: i64,
|
|
83
|
+
/// Timer provider mode: "auto" (authority-based), "vscode" (always delegate),
|
|
84
|
+
/// "mcp" (always internal), "off" (disabled). Default "auto".
|
|
85
|
+
#[serde(default = "default_timer_provider")]
|
|
86
|
+
pub timer_provider: String,
|
|
87
|
+
/// Heartbeat staleness threshold in seconds for authority.json. Default 30.
|
|
88
|
+
#[serde(default = "default_timer_authority_ttl_secs")]
|
|
89
|
+
pub timer_authority_ttl_secs: u64,
|
|
90
|
+
/// Idle timeout in minutes for MCP fallback timer. Default 10.
|
|
91
|
+
#[serde(default = "default_timer_idle_timeout_minutes")]
|
|
92
|
+
pub timer_idle_timeout_minutes: u64,
|
|
83
93
|
#[serde(default)]
|
|
84
94
|
pub custom_fields: HashMap<String, toml::Value>,
|
|
85
95
|
}
|
|
@@ -251,6 +261,18 @@ fn default_memory_injected_gc_days() -> i64 {
|
|
|
251
261
|
14
|
|
252
262
|
}
|
|
253
263
|
|
|
264
|
+
fn default_timer_provider() -> String {
|
|
265
|
+
"auto".to_string()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn default_timer_authority_ttl_secs() -> u64 {
|
|
269
|
+
30
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn default_timer_idle_timeout_minutes() -> u64 {
|
|
273
|
+
10
|
|
274
|
+
}
|
|
275
|
+
|
|
254
276
|
fn default_scan_dirs() -> Vec<String> {
|
|
255
277
|
vec!["~/pro/".to_string()]
|
|
256
278
|
}
|
|
@@ -270,6 +292,9 @@ impl Default for SettingsConfig {
|
|
|
270
292
|
memory_query_limit: default_memory_query_limit(),
|
|
271
293
|
memory_stale_days: default_memory_stale_days(),
|
|
272
294
|
memory_injected_gc_days: default_memory_injected_gc_days(),
|
|
295
|
+
timer_provider: default_timer_provider(),
|
|
296
|
+
timer_authority_ttl_secs: default_timer_authority_ttl_secs(),
|
|
297
|
+
timer_idle_timeout_minutes: default_timer_idle_timeout_minutes(),
|
|
273
298
|
custom_fields: HashMap::new(),
|
|
274
299
|
}
|
|
275
300
|
}
|