handoff-mcp-server 0.18.7 → 0.19.1
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 +3 -3
- package/Cargo.toml +2 -2
- package/README.md +89 -21
- package/package.json +1 -1
- package/scripts/install-local.sh +12 -0
- package/scripts/sync-plugin-version.sh +85 -0
- package/src/main.rs +66 -5
- package/src/setup.rs +179 -17
package/Cargo.lock
CHANGED
|
@@ -136,7 +136,7 @@ dependencies = [
|
|
|
136
136
|
|
|
137
137
|
[[package]]
|
|
138
138
|
name = "handoff-mcp"
|
|
139
|
-
version = "0.
|
|
139
|
+
version = "0.19.1"
|
|
140
140
|
dependencies = [
|
|
141
141
|
"anyhow",
|
|
142
142
|
"chrono",
|
|
@@ -208,9 +208,9 @@ dependencies = [
|
|
|
208
208
|
|
|
209
209
|
[[package]]
|
|
210
210
|
name = "lexsim"
|
|
211
|
-
version = "0.
|
|
211
|
+
version = "0.4.0"
|
|
212
212
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
213
|
-
checksum = "
|
|
213
|
+
checksum = "14b08f26a4b07325e83c0211adb36d0223fae644534dacd4eae72153c1ccd873"
|
|
214
214
|
dependencies = [
|
|
215
215
|
"unicode-normalization",
|
|
216
216
|
"unicode-segmentation",
|
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.19.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -20,7 +20,7 @@ toml_edit = "0.22"
|
|
|
20
20
|
chrono = { version = "0.4", features = ["serde"] }
|
|
21
21
|
anyhow = "1"
|
|
22
22
|
thiserror = "2"
|
|
23
|
-
lexsim = "0.1.0"
|
|
23
|
+
lexsim = ">=0.1.0, <1.0.0"
|
|
24
24
|
|
|
25
25
|
[dev-dependencies]
|
|
26
26
|
tempfile = "3.27.0"
|
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` —
|
|
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
|
|
141
|
-
|
|
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` |
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
package/scripts/install-local.sh
CHANGED
|
@@ -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)"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sync plugin/marketplace manifest versions to package.json's version.
|
|
3
|
+
#
|
|
4
|
+
# package.json is the source of truth (kept in sync with Cargo.toml manually
|
|
5
|
+
# per CLAUDE.md "Version sync" rule). This script propagates that version to:
|
|
6
|
+
# - plugin/.claude-plugin/plugin.json
|
|
7
|
+
# - plugin-hooks/.claude-plugin/plugin.json
|
|
8
|
+
# - plugin-task-loop/.claude-plugin/plugin.json
|
|
9
|
+
# - .claude-plugin/marketplace.json (all three "version" entries)
|
|
10
|
+
#
|
|
11
|
+
# Uses regex line replacement (not json.dump) so untouched formatting in
|
|
12
|
+
# these hand-maintained manifests is preserved byte-for-byte.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# ./scripts/sync-plugin-version.sh # fix mismatches in place
|
|
16
|
+
# ./scripts/sync-plugin-version.sh --check # exit 1 if anything is out of sync, fix nothing
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
20
|
+
ROOT="$(dirname "$SCRIPT_DIR")"
|
|
21
|
+
cd "$ROOT"
|
|
22
|
+
|
|
23
|
+
MODE="fix"
|
|
24
|
+
if [ "${1:-}" = "--check" ]; then
|
|
25
|
+
MODE="check"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
python3 - "$MODE" <<'PYEOF'
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
mode = sys.argv[1]
|
|
34
|
+
|
|
35
|
+
with open("package.json") as f:
|
|
36
|
+
root_version = json.load(f)["version"]
|
|
37
|
+
|
|
38
|
+
version_line_re = re.compile(r'^(\s*"version"\s*:\s*)"[^"]*"(,?\s*(?:\r?\n)?)$')
|
|
39
|
+
|
|
40
|
+
def sync_file(path, mismatches):
|
|
41
|
+
with open(path) as f:
|
|
42
|
+
lines = f.readlines()
|
|
43
|
+
changed = False
|
|
44
|
+
out = []
|
|
45
|
+
for line in lines:
|
|
46
|
+
m = version_line_re.match(line)
|
|
47
|
+
if m:
|
|
48
|
+
current = re.search(r'"version"\s*:\s*"([^"]*)"', line).group(1)
|
|
49
|
+
if current != root_version:
|
|
50
|
+
mismatches.append((path, current))
|
|
51
|
+
changed = True
|
|
52
|
+
line = f'{m.group(1)}"{root_version}"{m.group(2)}'
|
|
53
|
+
out.append(line)
|
|
54
|
+
if changed and mode == "fix":
|
|
55
|
+
with open(path, "w") as f:
|
|
56
|
+
f.writelines(out)
|
|
57
|
+
return changed
|
|
58
|
+
|
|
59
|
+
mismatches = []
|
|
60
|
+
plugin_files = [
|
|
61
|
+
"plugin/.claude-plugin/plugin.json",
|
|
62
|
+
"plugin-hooks/.claude-plugin/plugin.json",
|
|
63
|
+
"plugin-task-loop/.claude-plugin/plugin.json",
|
|
64
|
+
".claude-plugin/marketplace.json",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for path in plugin_files:
|
|
68
|
+
sync_file(path, mismatches)
|
|
69
|
+
|
|
70
|
+
if mode == "check":
|
|
71
|
+
if mismatches:
|
|
72
|
+
print(f"Version mismatch: package.json is {root_version}, but found:")
|
|
73
|
+
for path, version in mismatches:
|
|
74
|
+
print(f" {path}: {version}")
|
|
75
|
+
print("Run ./scripts/sync-plugin-version.sh to fix.")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
print(f"All plugin/marketplace manifests match package.json ({root_version}).")
|
|
78
|
+
else:
|
|
79
|
+
if mismatches:
|
|
80
|
+
print(f"Synced {len(mismatches)} version occurrence(s) to {root_version}:")
|
|
81
|
+
for path, old_version in mismatches:
|
|
82
|
+
print(f" {path}: {old_version} -> {root_version}")
|
|
83
|
+
else:
|
|
84
|
+
print(f"Already in sync at version {root_version}. No changes made.")
|
|
85
|
+
PYEOF
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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!("\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
338
|
+
fn build_hooks_has_two_events() {
|
|
271
339
|
let hooks = build_hooks_config();
|
|
272
|
-
assert_eq!(hooks.len(),
|
|
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();
|