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.
@@ -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!("Parent task not found: {pid}"))?;
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
- anyhow::anyhow!(
158
- "Task '{task_id}' does not exist and cannot be created without a title. \
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)?.ok_or_else(|| {
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!("Task not found: {task_id}"))?;
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)?.ok_or_else(|| {
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)?.ok_or_else(|| {
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: "memory_save".to_string(),
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: "memory_query".to_string(),
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: "memory_delete".to_string(),
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: "memory_cleanup".to_string(),
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
+ }
@@ -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
  }