handoff-mcp-server 0.18.6 → 0.19.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 CHANGED
@@ -136,7 +136,7 @@ dependencies = [
136
136
 
137
137
  [[package]]
138
138
  name = "handoff-mcp"
139
- version = "0.18.6"
139
+ version = "0.19.0"
140
140
  dependencies = [
141
141
  "anyhow",
142
142
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.18.6"
3
+ version = "0.19.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -35,28 +35,38 @@ At session start, the agent calls `handoff_load_context` to pick up where things
35
35
 
36
36
  ### Claude Code Plugin (recommended)
37
37
 
38
- The easiest way to install handoff-mcp is as a Claude Code plugin:
38
+ The easiest way to install handoff-mcp is as a Claude Code plugin.
39
39
 
40
40
  ```bash
41
41
  # 1. Install the binary (required — the plugin calls it)
42
42
  npm install -g handoff-mcp-server
43
43
  # or: cargo install handoff-mcp
44
44
 
45
- # 2. Add the marketplace
45
+ # 2. Add the marketplace (GitHub repo)
46
46
  /plugin marketplace add alphaelements/handoff-mcp
47
47
 
48
48
  # 3. Install the plugin (MCP server + skills)
49
49
  /plugin install handoff-mcp@handoff-mcp-marketplace
50
+
51
+ # 4. Apply the change
52
+ /reload-plugins
50
53
  ```
51
54
 
52
55
  This registers the MCP server and all skills automatically — no manual
53
- `.mcp.json` or skill file setup needed.
56
+ `.mcp.json` or skill file setup needed. The `handoff-mcp` plugin is enabled
57
+ on install, so no separate `/plugin enable` is needed. Run `/reload-plugins`
58
+ to pick up the change mid-session (a Claude Code restart also applies it).
59
+
60
+ > **Naming**: `handoff-mcp` is the *plugin* name; `handoff-mcp-marketplace`
61
+ > is the *marketplace* name (the `name` field in `.claude-plugin/marketplace.json`).
62
+ > Install commands always use `<plugin>@<marketplace>`.
54
63
 
55
64
  **Optional: task loop (automated TDD + research workflows)**
56
65
 
57
66
  ```bash
58
67
  /plugin install handoff-task-loop@handoff-mcp-marketplace
59
- /plugin enable handoff-task-loop
68
+ /plugin enable handoff-task-loop@handoff-mcp-marketplace
69
+ /reload-plugins
60
70
  ```
61
71
 
62
72
  Adds `/session-loop` (parallel TDD implementation, adversarial testing, Opus
@@ -67,12 +77,52 @@ drafting). See [plugin-task-loop/README.md](plugin-task-loop/README.md).
67
77
 
68
78
  ```bash
69
79
  /plugin install handoff-mcp-hooks@handoff-mcp-marketplace
70
- /plugin enable handoff-mcp-hooks
80
+ /plugin enable handoff-mcp-hooks@handoff-mcp-marketplace
81
+ /reload-plugins
71
82
  ```
72
83
 
73
84
  This adds hooks that inject relevant project memories on every prompt and file
74
- edit. Disable anytime with `/plugin disable handoff-mcp-hooks` — the MCP server
75
- and skills remain active.
85
+ edit. Disable anytime with `/plugin disable handoff-mcp-hooks@handoff-mcp-marketplace` —
86
+ the MCP server and skills remain active.
87
+
88
+ > The `handoff-task-loop` and `handoff-mcp-hooks` plugins ship with
89
+ > `defaultEnabled: false`, so they need an explicit `/plugin enable` step after
90
+ > install. The main `handoff-mcp` plugin is `defaultEnabled: true` and skips it.
91
+
92
+ **Installing the local development version instead**
93
+
94
+ If you are hacking on handoff-mcp and want Claude Code to load your local
95
+ checkout rather than the published GitHub version, register the repository root
96
+ (the directory containing `.claude-plugin/marketplace.json`) as a local
97
+ marketplace:
98
+
99
+ ```bash
100
+ # 1. Build the binary and sync skills + plugin caches
101
+ ./scripts/install-local.sh
102
+
103
+ # 2. Register the repo root as a local marketplace (first time only).
104
+ # Use the local path here — not the alphaelements/handoff-mcp shorthand.
105
+ /plugin marketplace add /absolute/path/to/handoff-mcp
106
+
107
+ # 3. Install and apply
108
+ /plugin install handoff-mcp@handoff-mcp-marketplace
109
+ /reload-plugins
110
+ ```
111
+
112
+ After the first setup, re-run `./scripts/install-local.sh` whenever you change
113
+ the code, then restart Claude Code (or `/reload-plugins`) to load the rebuilt
114
+ version. Note that `install-local.sh` **only rebuilds the binary and refreshes
115
+ the plugin cache** — it does not register the marketplace or enable the plugin,
116
+ so steps 2 and 3 are a one-time bootstrap.
117
+
118
+ **Troubleshooting**
119
+
120
+ - **Plugin or skills don't show up** — run `/reload-plugins`, or restart Claude
121
+ Code. As a last resort, `rm -rf ~/.claude/plugins/cache` and reinstall.
122
+ - **`plugin not found`** — refresh the catalog with
123
+ `/plugin marketplace update handoff-mcp-marketplace`, then reinstall.
124
+ - **MCP server won't start** — open `/plugin` → **Errors** tab, and confirm the
125
+ binary is on your `PATH` (`which handoff-mcp`).
76
126
 
77
127
  ### cargo
78
128
 
@@ -137,8 +187,8 @@ handoff-mcp setup
137
187
  ```
138
188
 
139
189
  This installs Claude Code hooks into `~/.claude/settings.json` that
140
- automatically call `handoff_memory_query` on every prompt and file edit, and run
141
- `handoff_memory_cleanup` at session start. Restart Claude Code after running setup.
190
+ automatically call `handoff_memory_query` on every prompt and file edit.
191
+ Restart Claude Code after running setup.
142
192
 
143
193
  You can check the current status or remove the hooks:
144
194
 
@@ -258,7 +308,7 @@ rename) so a concurrent reader never sees a partially-written file.
258
308
  | `handoff_memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
259
309
  | `handoff_memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
260
310
  | `handoff_memory_delete` | Delete a memory by id (full id or unique prefix) |
261
- | `handoff_memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
311
+ | `handoff_memory_cleanup` | Manual/CLI housekeeping: silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
262
312
 
263
313
  For usage best practices (granularity, scope_paths, conflict handling, cleanup), see `skills/handoff-memory/SKILL.md`.
264
314
  See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
@@ -432,7 +482,9 @@ the same memory is **not injected twice in one session** — and an *edited* mem
432
482
  |-------|-------|--------|
433
483
  | `UserPromptSubmit` | `handoff_memory_query` (prompt text) | Inject memories relevant to the prompt |
434
484
  | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path) | Inject memories scoped to the file being edited |
435
- | `SessionStart` | `handoff_memory_cleanup` | Merge exact duplicates, gc old sidecars |
485
+
486
+ `handoff_memory_cleanup` (merge exact duplicates, gc old sidecars) is not wired
487
+ to a hook — call it manually or from a CLI/cron job when you want housekeeping.
436
488
 
437
489
  > **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
438
490
  > personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
@@ -462,12 +514,6 @@ MCP tool from a hook directly, with no wrapper script. In
462
514
  "text": "${tool_input.file_path}",
463
515
  "file_paths": ["${tool_input.file_path}"] }
464
516
  } ] }
465
- ],
466
- "SessionStart": [
467
- { "hooks": [ {
468
- "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup",
469
- "input": { "project_dir": "${cwd}" }
470
- } ] }
471
517
  ]
472
518
  }
473
519
  }
@@ -493,10 +539,6 @@ both paths parse it identically. Point all three hooks at it:
493
539
  "PreToolUse": [
494
540
  { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command",
495
541
  "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
496
- ],
497
- "SessionStart": [
498
- { "hooks": [ { "type": "command",
499
- "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
500
542
  ]
501
543
  }
502
544
  }
@@ -506,6 +548,32 @@ The script resolves the `handoff-mcp` binary from `PATH` (override with
506
548
  `HANDOFF_MCP_BIN`) and **fails safe**: on any error it prints nothing and exits
507
549
  0, so a memory miss is silent and never blocks your prompt.
508
550
 
551
+ ### Upgrading from a version with a `SessionStart` cleanup hook
552
+
553
+ Versions before this fix had `handoff-mcp setup` (and the
554
+ `handoff-mcp-hooks` plugin) install a **synchronous `SessionStart` hook**
555
+ that ran `handoff_memory_cleanup` on every session start. Under many
556
+ parallel sub-agents (e.g. `/research-loop`), that hook could pile up heavy
557
+ cleanup calls on the single-threaded server and hang your editor.
558
+
559
+ The `SessionStart` cleanup hook has been removed entirely — `memory_cleanup`
560
+ is still available, but only via manual/CLI invocation, never auto-fired.
561
+ If you already ran `handoff-mcp setup` before this change, migrate with
562
+ **one** of the following:
563
+
564
+ - **Re-run setup** (recommended): `handoff-mcp setup`. It now detects and
565
+ automatically strips the legacy `SessionStart` cleanup hook while leaving
566
+ your other handoff hooks untouched. Use `handoff-mcp setup --check` first
567
+ if you want to confirm whether the legacy hook is present before touching
568
+ anything.
569
+ - **Manual edit**: open `~/.claude/settings.json` and delete the
570
+ `hooks.SessionStart` entry whose `tool` is `handoff_memory_cleanup` (remove
571
+ the whole `SessionStart` key if that was its only entry).
572
+ - **Plugin users**: `/plugin update` to pick up the new `hooks.json`, then
573
+ restart Claude Code.
574
+
575
+ Restart Claude Code after any of the above for the change to take effect.
576
+
509
577
  ### Memory settings
510
578
 
511
579
  All under `[settings]` in `.handoff/config.toml`, all with safe defaults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.18.6",
3
+ "version": "0.19.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>",
@@ -1,6 +1,18 @@
1
1
  #!/usr/bin/env bash
2
2
  # Install handoff-mcp locally: binary, skills, and plugin caches.
3
3
  # Usage: ./scripts/install-local.sh
4
+ #
5
+ # SCOPE: this script rebuilds the binary and refreshes the plugin CACHE only.
6
+ # It does NOT register the marketplace (known_marketplaces.json) or enable the
7
+ # plugin (enabledPlugins in settings.json), and it only updates
8
+ # installed_plugins.json entries that already exist. So on a fresh machine it
9
+ # does not activate the plugin by itself — bootstrap once with:
10
+ # /plugin marketplace add /absolute/path/to/handoff-mcp
11
+ # /plugin install handoff-mcp@handoff-mcp-marketplace
12
+ # /reload-plugins
13
+ # Thereafter, re-run this script to pick up code changes, then restart Claude
14
+ # Code (or /reload-plugins). See README "Installing the local development
15
+ # version instead".
4
16
  set -euo pipefail
5
17
 
6
18
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
package/src/main.rs CHANGED
@@ -1,7 +1,26 @@
1
1
  use std::io::{self, BufRead, Write};
2
+ use std::sync::mpsc;
3
+ use std::thread;
4
+ use std::time::Duration;
2
5
 
3
6
  use handoff_mcp::mcp::protocol::process_line;
4
7
 
8
+ /// Maximum time a single JSON-RPC request may take before the server gives
9
+ /// up waiting and returns a fail-safe error response. Processing continues
10
+ /// on the worker thread in the background; the timeout only bounds how long
11
+ /// the main loop blocks waiting for a reply. Override for tests via
12
+ /// `HANDOFF_MCP_REQUEST_TIMEOUT_SECS` (parsed once at startup; falls back to
13
+ /// the 30s default on missing/invalid values).
14
+ const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
15
+
16
+ fn request_timeout() -> Duration {
17
+ let secs = std::env::var("HANDOFF_MCP_REQUEST_TIMEOUT_SECS")
18
+ .ok()
19
+ .and_then(|v| v.parse::<u64>().ok())
20
+ .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);
21
+ Duration::from_secs(secs)
22
+ }
23
+
5
24
  fn main() {
6
25
  let args: Vec<String> = std::env::args().collect();
7
26
 
@@ -46,6 +65,7 @@ fn main() {
46
65
 
47
66
  let stdin = io::stdin();
48
67
  let mut stdout = io::stdout();
68
+ let timeout = request_timeout();
49
69
 
50
70
  for line in stdin.lock().lines() {
51
71
  let line = match line {
@@ -56,12 +76,53 @@ fn main() {
56
76
  }
57
77
  };
58
78
 
59
- if let Some(response) = process_line(&line) {
60
- if writeln!(stdout, "{response}").is_err() {
61
- break;
79
+ // Run this request on a dedicated worker thread so a slow handler
80
+ // can't block the main loop forever: `recv_timeout` below bounds how
81
+ // long we wait for a reply. The next line is only read after this
82
+ // request's worker completes (or times out) — sequential processing
83
+ // order is preserved; this is a fail-safe timeout, not parallelism.
84
+ let (tx, rx) = mpsc::channel();
85
+ let line_for_worker = line.clone();
86
+ thread::spawn(move || {
87
+ let result = process_line(&line_for_worker);
88
+ let _ = tx.send(result);
89
+ });
90
+
91
+ match rx.recv_timeout(timeout) {
92
+ Ok(Some(response)) => {
93
+ if writeln!(stdout, "{response}").is_err() {
94
+ break;
95
+ }
96
+ if stdout.flush().is_err() {
97
+ break;
98
+ }
62
99
  }
63
- if stdout.flush().is_err() {
64
- break;
100
+ Ok(None) => {
101
+ // Notification: no response expected.
102
+ }
103
+ Err(mpsc::RecvTimeoutError::Timeout) => {
104
+ eprintln!("Request timed out after {}s", timeout.as_secs());
105
+ let id = serde_json::from_str::<serde_json::Value>(&line)
106
+ .ok()
107
+ .and_then(|req| req.get("id").cloned())
108
+ .unwrap_or(serde_json::Value::Null);
109
+ let error_response = serde_json::json!({
110
+ "jsonrpc": "2.0",
111
+ "id": id,
112
+ "error": {
113
+ "code": -32603,
114
+ "message": format!("Request timed out after {}s", timeout.as_secs())
115
+ }
116
+ });
117
+ if writeln!(stdout, "{error_response}").is_err() {
118
+ break;
119
+ }
120
+ if stdout.flush().is_err() {
121
+ break;
122
+ }
123
+ }
124
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
125
+ eprintln!("Worker thread disconnected without a response");
65
126
  }
66
127
  }
67
128
  }
package/src/setup.rs CHANGED
@@ -6,7 +6,6 @@ use serde_json::Value;
6
6
 
7
7
  const HOOK_SERVER: &str = "handoff";
8
8
  const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
9
- const HOOK_TOOL_CLEANUP: &str = "handoff_memory_cleanup";
10
9
 
11
10
  fn settings_path() -> Result<PathBuf> {
12
11
  let home = std::env::var("HOME")
@@ -77,15 +76,6 @@ fn build_hooks_config() -> BTreeMap<&'static str, Value> {
77
76
  }]),
78
77
  );
79
78
 
80
- hooks.insert(
81
- "SessionStart",
82
- serde_json::json!([{
83
- "hooks": [mcp_tool_hook(HOOK_TOOL_CLEANUP, serde_json::json!({
84
- "project_dir": "${CLAUDE_PROJECT_DIR}"
85
- }))]
86
- }]),
87
- );
88
-
89
79
  hooks
90
80
  }
91
81
 
@@ -106,6 +96,54 @@ fn has_handoff_hook(arr: &Value) -> bool {
106
96
  false
107
97
  }
108
98
 
99
+ /// Legacy event that used to carry a synchronous `handoff_memory_cleanup`
100
+ /// hook. Removed as a desired hook in this fix (see wiki/100-stdio-concurrency.md)
101
+ /// because it was the trigger for the VSCode hang under many parallel
102
+ /// sub-agents. Still used to detect and migrate away stale installs from
103
+ /// before this fix.
104
+ const LEGACY_SESSION_START_EVENT: &str = "SessionStart";
105
+
106
+ /// True if `settings` still has a handoff-owned hook registered under the
107
+ /// legacy `SessionStart` event (i.e. an install performed before this fix
108
+ /// removed the synchronous cleanup hook). Used both to auto-migrate on
109
+ /// `setup` re-run and to flag the issue in `setup --check`.
110
+ fn has_legacy_session_start_hook(settings: &Value) -> bool {
111
+ settings
112
+ .get("hooks")
113
+ .and_then(|h| h.get(LEGACY_SESSION_START_EVENT))
114
+ .map(has_handoff_hook)
115
+ .unwrap_or(false)
116
+ }
117
+
118
+ /// Removes handoff-owned hook entries from the legacy `SessionStart` event,
119
+ /// leaving any other (non-handoff) hooks registered under that event intact.
120
+ /// Returns true if anything was removed.
121
+ fn strip_legacy_session_start_hook(hooks_obj: &mut serde_json::Map<String, Value>) -> bool {
122
+ let Some(arr) = hooks_obj
123
+ .get_mut(LEGACY_SESSION_START_EVENT)
124
+ .and_then(|v| v.as_array_mut())
125
+ else {
126
+ return false;
127
+ };
128
+
129
+ let before = arr.len();
130
+ arr.retain(|entry| {
131
+ let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
132
+ return true;
133
+ };
134
+ !hooks
135
+ .iter()
136
+ .any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
137
+ });
138
+ let removed = arr.len() != before;
139
+
140
+ if arr.is_empty() {
141
+ hooks_obj.remove(LEGACY_SESSION_START_EVENT);
142
+ }
143
+
144
+ removed
145
+ }
146
+
109
147
  pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
110
148
  anyhow::ensure!(
111
149
  !(check_only && uninstall),
@@ -147,10 +185,22 @@ fn run_check(settings: &Value, path: &Path) -> Result<()> {
147
185
  }
148
186
  }
149
187
 
188
+ if has_legacy_session_start_hook(settings) {
189
+ println!(
190
+ " {LEGACY_SESSION_START_EVENT}: legacy handoff_memory_cleanup hook found (removed in this version)"
191
+ );
192
+ println!(
193
+ "\nWARNING: a synchronous SessionStart cleanup hook from an older install was \
194
+ found. This is the known trigger for VSCode hangs under many parallel \
195
+ sub-agents. Run `handoff-mcp setup` (without --check) to remove it."
196
+ );
197
+ all_ok = false;
198
+ }
199
+
150
200
  if all_ok {
151
201
  println!("\nAll hooks are configured. Memory auto-injection is active.");
152
202
  } else {
153
- println!("\nSome hooks are missing. Run `handoff-mcp setup` to install them.");
203
+ println!("\nHooks need attention. Run `handoff-mcp setup` to install/migrate them.");
154
204
  }
155
205
 
156
206
  Ok(())
@@ -197,10 +247,28 @@ fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
197
247
  installed += 1;
198
248
  }
199
249
 
200
- if installed > 0 {
250
+ // Migrate away from a pre-fix install: an older `handoff-mcp setup` used
251
+ // to write a synchronous SessionStart `handoff_memory_cleanup` hook,
252
+ // which is the confirmed trigger for the VSCode hang under many
253
+ // parallel sub-agents (wiki/100-stdio-concurrency.md). Strip it here so
254
+ // that simply re-running `setup` remediates existing installs.
255
+ let migrated = strip_legacy_session_start_hook(hooks_obj);
256
+ if migrated {
257
+ println!(
258
+ " {LEGACY_SESSION_START_EVENT}: removed legacy handoff_memory_cleanup hook \
259
+ (known VSCode hang trigger)"
260
+ );
261
+ }
262
+
263
+ if installed > 0 || migrated {
201
264
  write_settings(path, settings)?;
202
265
  println!("\nWrote {path}", path = path.display());
203
- println!("{installed} hook(s) installed, {skipped} already present.");
266
+ if installed > 0 {
267
+ println!("{installed} hook(s) installed, {skipped} already present.");
268
+ }
269
+ if migrated {
270
+ println!("Legacy SessionStart cleanup hook removed. See README for migration details.");
271
+ }
204
272
  println!("\nRestart Claude Code for hooks to take effect.");
205
273
  } else {
206
274
  println!("\nAll hooks already installed. Nothing to do.");
@@ -267,12 +335,12 @@ mod tests {
267
335
  use super::*;
268
336
 
269
337
  #[test]
270
- fn build_hooks_has_three_events() {
338
+ fn build_hooks_has_two_events() {
271
339
  let hooks = build_hooks_config();
272
- assert_eq!(hooks.len(), 3);
340
+ assert_eq!(hooks.len(), 2);
273
341
  assert!(hooks.contains_key("UserPromptSubmit"));
274
342
  assert!(hooks.contains_key("PreToolUse"));
275
- assert!(hooks.contains_key("SessionStart"));
343
+ assert!(!hooks.contains_key("SessionStart"));
276
344
  }
277
345
 
278
346
  #[test]
@@ -309,7 +377,7 @@ mod tests {
309
377
  let hooks = written.get("hooks").unwrap().as_object().unwrap();
310
378
  assert!(hooks.contains_key("UserPromptSubmit"));
311
379
  assert!(hooks.contains_key("PreToolUse"));
312
- assert!(hooks.contains_key("SessionStart"));
380
+ assert!(!hooks.contains_key("SessionStart"));
313
381
  }
314
382
 
315
383
  #[test]
@@ -349,6 +417,10 @@ mod tests {
349
417
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
350
418
  let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
351
419
  assert_eq!(user_prompt.len(), 1);
420
+ assert!(!written["hooks"]
421
+ .as_object()
422
+ .unwrap()
423
+ .contains_key("SessionStart"));
352
424
  }
353
425
 
354
426
  #[test]
@@ -400,6 +472,96 @@ mod tests {
400
472
  );
401
473
  }
402
474
 
475
+ #[test]
476
+ fn install_removes_legacy_session_start_cleanup_hook() {
477
+ let dir = tempfile::tempdir().unwrap();
478
+ let path = dir.path().join("settings.json");
479
+
480
+ // Simulate a settings.json written by a pre-fix version of
481
+ // `handoff-mcp setup`, which still installed a SessionStart
482
+ // cleanup hook (the very hang trigger this task removes).
483
+ let mut settings = serde_json::json!({
484
+ "hooks": {
485
+ "SessionStart": [{
486
+ "hooks": [{
487
+ "type": "mcp_tool",
488
+ "server": "handoff",
489
+ "tool": "handoff_memory_cleanup",
490
+ "input": {"project_dir": "${CLAUDE_PROJECT_DIR}"}
491
+ }]
492
+ }],
493
+ "UserPromptSubmit": [{
494
+ "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
495
+ }],
496
+ "PreToolUse": [{
497
+ "matcher": "Edit|Write|MultiEdit",
498
+ "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
499
+ }]
500
+ }
501
+ });
502
+
503
+ run_install(&mut settings, &path).unwrap();
504
+
505
+ let written: Value =
506
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
507
+ let hooks = written.get("hooks").unwrap().as_object().unwrap();
508
+ assert!(
509
+ !hooks.contains_key("SessionStart"),
510
+ "legacy SessionStart cleanup hook must be removed by re-running setup"
511
+ );
512
+ assert!(hooks.contains_key("UserPromptSubmit"));
513
+ assert!(hooks.contains_key("PreToolUse"));
514
+ }
515
+
516
+ #[test]
517
+ fn install_preserves_non_handoff_session_start_hooks() {
518
+ let dir = tempfile::tempdir().unwrap();
519
+ let path = dir.path().join("settings.json");
520
+
521
+ let mut settings = serde_json::json!({
522
+ "hooks": {
523
+ "SessionStart": [
524
+ {"hooks": [{"type": "command", "command": "my-other-hook"}]},
525
+ {"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup"}]}
526
+ ]
527
+ }
528
+ });
529
+
530
+ run_install(&mut settings, &path).unwrap();
531
+
532
+ let written: Value =
533
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
534
+ let session_start = written["hooks"]["SessionStart"].as_array().unwrap();
535
+ assert_eq!(session_start.len(), 1);
536
+ assert!(!has_handoff_hook(&written["hooks"]["SessionStart"]));
537
+ }
538
+
539
+ #[test]
540
+ fn check_flags_legacy_session_start_cleanup_hook() {
541
+ let settings = serde_json::json!({
542
+ "hooks": {
543
+ "SessionStart": [{
544
+ "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup"}]
545
+ }]
546
+ }
547
+ });
548
+
549
+ assert!(has_legacy_session_start_hook(&settings));
550
+ }
551
+
552
+ #[test]
553
+ fn check_does_not_flag_when_no_legacy_hook_present() {
554
+ let settings = serde_json::json!({
555
+ "hooks": {
556
+ "UserPromptSubmit": [{
557
+ "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
558
+ }]
559
+ }
560
+ });
561
+
562
+ assert!(!has_legacy_session_start_hook(&settings));
563
+ }
564
+
403
565
  #[test]
404
566
  fn uninstall_preserves_other_hooks() {
405
567
  let dir = tempfile::tempdir().unwrap();