handoff-mcp-server 0.26.0 → 0.28.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,276 +0,0 @@
1
- //! Shared BM25 + scope-path ranking used by `memory_query` and (from t96.3)
2
- //! `doc_query`.
3
- //!
4
- //! Extracted from the original `memory_query` implementation
5
- //! (`src/mcp/handlers/memory.rs`) so both features rank candidates the same
6
- //! way: BM25 relevance over a `lexsim::Corpus`, a fixed bonus when a
7
- //! candidate's `scope_paths` prefix-matches one of the query's `file_paths`,
8
- //! a `min_score` floor, then a stable sort + `limit` truncation.
9
-
10
- /// One ranked candidate: the index into the caller's original slice, plus its
11
- /// final score (BM25 + scope bonus).
12
- #[derive(Debug, Clone, Copy, PartialEq)]
13
- pub struct RankItem {
14
- pub index: usize,
15
- pub score: f64,
16
- }
17
-
18
- /// Tuning knobs for [`rank_by_bm25_and_scope`].
19
- #[derive(Debug, Clone, Copy)]
20
- pub struct RankConfig {
21
- /// Candidates scoring below this are dropped before sorting.
22
- pub min_score: f64,
23
- /// Relative threshold (0.0–1.0): after ranking, a candidate is dropped
24
- /// unless `score >= top_score * relative_threshold`. 0.0 disables.
25
- pub relative_threshold: f64,
26
- /// Added to a candidate's BM25 score when [`scope_matches`] is true.
27
- pub scope_path_bonus: f64,
28
- /// Max number of items returned (applied after sort, before session diff).
29
- pub limit: usize,
30
- }
31
-
32
- /// True if any `scope` prefix matches any `file` path (substring match on the
33
- /// path, not a strict prefix — mirrors the original `memory_query` behavior).
34
- pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
35
- if scopes.is_empty() || files.is_empty() {
36
- return false;
37
- }
38
- scopes
39
- .iter()
40
- .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
41
- }
42
-
43
- /// Rank every document in `corpus` against `query_tokens` via weighted BM25,
44
- /// add `config.scope_path_bonus` when `scope_paths[i]` matches `file_paths`,
45
- /// drop anything below `config.min_score`, sort descending by score, and
46
- /// truncate to `config.limit`.
47
- ///
48
- /// `scope_paths` and the corpus must be index-aligned (one entry per
49
- /// document); `corpus.len()` and `scope_paths.len()` are expected to match —
50
- /// a mismatch simply means the extra `scope_paths` entries are never
51
- /// consulted (indices beyond `corpus.len()` are not produced).
52
- pub fn rank_by_bm25_and_scope(
53
- corpus: &lexsim::Corpus,
54
- query_tokens: &[lexsim::WeightedToken],
55
- scope_paths: &[Vec<String>],
56
- file_paths: &[String],
57
- config: &RankConfig,
58
- ) -> Vec<RankItem> {
59
- let scores = corpus.bm25_scores_weighted_tokens(query_tokens);
60
-
61
- let mut ranked: Vec<RankItem> = scores
62
- .into_iter()
63
- .enumerate()
64
- .map(|(index, mut score)| {
65
- if let Some(scopes) = scope_paths.get(index) {
66
- if scope_matches(scopes, file_paths) {
67
- score += config.scope_path_bonus;
68
- }
69
- }
70
- RankItem { index, score }
71
- })
72
- .filter(|item| item.score > 0.0 && item.score >= config.min_score)
73
- .collect();
74
-
75
- ranked.sort_by(|a, b| {
76
- b.score
77
- .partial_cmp(&a.score)
78
- .unwrap_or(std::cmp::Ordering::Equal)
79
- });
80
-
81
- // Relative threshold: drop candidates whose score is below a fraction of
82
- // the top hit. Applied after sorting so `ranked[0]` is the best match.
83
- if config.relative_threshold > 0.0 {
84
- if let Some(top) = ranked.first() {
85
- let floor = top.score * config.relative_threshold;
86
- ranked.retain(|item| item.score >= floor);
87
- }
88
- }
89
-
90
- ranked.truncate(config.limit);
91
- ranked
92
- }
93
-
94
- /// Drop already-injected candidates (per the caller's session sidecar) from
95
- /// `ranked`, then truncate the survivors to `limit`.
96
- ///
97
- /// `already_injected(index)` receives the original document index (as stored
98
- /// on [`RankItem::index`]) and returns true when that document was already
99
- /// injected into the current session at its current content hash.
100
- pub fn filter_already_injected<F>(
101
- ranked: Vec<RankItem>,
102
- already_injected: F,
103
- limit: usize,
104
- ) -> Vec<RankItem>
105
- where
106
- F: Fn(usize) -> bool,
107
- {
108
- ranked
109
- .into_iter()
110
- .filter(|item| !already_injected(item.index))
111
- .take(limit)
112
- .collect()
113
- }
114
-
115
- #[cfg(test)]
116
- mod tests {
117
- use super::*;
118
-
119
- fn docs() -> Vec<String> {
120
- vec![
121
- "rust error handling with Result and anyhow".to_string(),
122
- "javascript promises and async await".to_string(),
123
- "rust ownership borrow checker ownership".to_string(),
124
- ]
125
- }
126
-
127
- fn default_config() -> RankConfig {
128
- RankConfig {
129
- min_score: 0.0,
130
- relative_threshold: 0.0,
131
- scope_path_bonus: 2.0,
132
- limit: 10,
133
- }
134
- }
135
-
136
- #[test]
137
- fn scope_matches_prefix() {
138
- let scopes = vec!["src/storage/".to_string()];
139
- let files = vec!["/repo/src/storage/mod.rs".to_string()];
140
- assert!(scope_matches(&scopes, &files));
141
- let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
142
- assert!(!scope_matches(&scopes, &files2));
143
- }
144
-
145
- #[test]
146
- fn scope_matches_empty_inputs_false() {
147
- assert!(!scope_matches(&[], &["a".to_string()]));
148
- assert!(!scope_matches(&["a".to_string()], &[]));
149
- }
150
-
151
- #[test]
152
- fn rank_by_bm25_orders_relevant_docs_first() {
153
- let corpus = lexsim::Corpus::build_weighted(&docs());
154
- let query_tokens = lexsim::tokenize_weighted("rust ownership");
155
- let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
156
- let ranked =
157
- rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
158
-
159
- assert!(!ranked.is_empty());
160
- assert_eq!(ranked[0].index, 2);
161
- }
162
-
163
- #[test]
164
- fn rank_by_bm25_applies_scope_path_bonus() {
165
- let corpus = lexsim::Corpus::build_weighted(&docs());
166
- let query_tokens = lexsim::tokenize_weighted("javascript");
167
- let scope_paths: Vec<Vec<String>> = vec![vec![], vec!["src/web/".to_string()], vec![]];
168
- let file_paths = vec!["/repo/src/web/app.js".to_string()];
169
- let config = RankConfig {
170
- min_score: 0.0,
171
- relative_threshold: 0.0,
172
- scope_path_bonus: 2.0,
173
- limit: 10,
174
- };
175
- let ranked =
176
- rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &file_paths, &config);
177
- assert_eq!(ranked[0].index, 1);
178
- assert!(ranked[0].score >= 2.0);
179
- }
180
-
181
- #[test]
182
- fn rank_by_bm25_filters_below_min_score() {
183
- let corpus = lexsim::Corpus::build_weighted(&docs());
184
- let query_tokens = lexsim::tokenize_weighted("completely unrelated gibberish zzz");
185
- let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
186
- let config = RankConfig {
187
- min_score: 0.01,
188
- relative_threshold: 0.0,
189
- scope_path_bonus: 2.0,
190
- limit: 10,
191
- };
192
- let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
193
- assert!(ranked.is_empty());
194
- }
195
-
196
- #[test]
197
- fn rank_by_bm25_respects_limit() {
198
- let corpus = lexsim::Corpus::build_weighted(&docs());
199
- let query_tokens = lexsim::tokenize_weighted("rust javascript");
200
- let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
201
- let config = RankConfig {
202
- min_score: 0.0,
203
- relative_threshold: 0.0,
204
- scope_path_bonus: 2.0,
205
- limit: 1,
206
- };
207
- let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
208
- assert_eq!(ranked.len(), 1);
209
- }
210
-
211
- #[test]
212
- fn rank_by_bm25_applies_relative_threshold() {
213
- let corpus = lexsim::Corpus::build_weighted(&docs());
214
- let query_tokens = lexsim::tokenize_weighted("rust ownership");
215
- let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
216
- let all =
217
- rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
218
- assert!(all.len() >= 2, "need at least 2 results to test relative");
219
- let config_rel = RankConfig {
220
- min_score: 0.0,
221
- relative_threshold: 0.95,
222
- scope_path_bonus: 0.0,
223
- limit: 10,
224
- };
225
- let filtered =
226
- rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config_rel);
227
- assert!(
228
- filtered.len() < all.len(),
229
- "relative threshold should drop low-scoring tail"
230
- );
231
- assert_eq!(filtered[0].index, all[0].index, "top hit must survive");
232
- }
233
-
234
- #[test]
235
- fn filter_already_injected_drops_marked_and_respects_limit() {
236
- let ranked = vec![
237
- RankItem {
238
- index: 0,
239
- score: 5.0,
240
- },
241
- RankItem {
242
- index: 1,
243
- score: 4.0,
244
- },
245
- RankItem {
246
- index: 2,
247
- score: 3.0,
248
- },
249
- ];
250
- let already = |i: usize| i == 1;
251
- let out = filter_already_injected(ranked, already, 10);
252
- assert_eq!(out.iter().map(|i| i.index).collect::<Vec<_>>(), vec![0, 2]);
253
- }
254
-
255
- #[test]
256
- fn filter_already_injected_applies_limit_after_filtering() {
257
- let ranked = vec![
258
- RankItem {
259
- index: 0,
260
- score: 5.0,
261
- },
262
- RankItem {
263
- index: 1,
264
- score: 4.0,
265
- },
266
- RankItem {
267
- index: 2,
268
- score: 3.0,
269
- },
270
- ];
271
- let out = filter_already_injected(ranked, |_| false, 2);
272
- assert_eq!(out.len(), 2);
273
- assert_eq!(out[0].index, 0);
274
- assert_eq!(out[1].index, 1);
275
- }
276
- }
@@ -1,129 +0,0 @@
1
- //! Shared ranking + corpus-cache infrastructure for full-text search across
2
- //! features (`memory_query` today, `doc_query` from t96.3).
3
- //!
4
- //! See `wiki/130-document-management.md` §3.1 for the design rationale: a
5
- //! single BM25 corpus cache, invalidated by a generation counter bumped on
6
- //! every mutating call (`doc_save`/`doc_delete`/`doc_import`), avoids
7
- //! rebuilding the corpus on every query once fragment counts reach the
8
- //! thousands. `memory_query` stays on the un-cached path (memory counts are
9
- //! small enough that a per-call rebuild is cheap) and only adopts the shared
10
- //! ranking function from [`injection`].
11
-
12
- pub mod injection;
13
-
14
- use std::sync::{Mutex, OnceLock};
15
-
16
- /// Process-wide cache of the document-fragment BM25 corpus, invalidated by a
17
- /// monotonically increasing generation counter.
18
- ///
19
- /// `doc_save`/`doc_delete`/`doc_import` call [`CorpusCache::increment_generation`]
20
- /// on every mutation; the next [`CorpusCache::get_or_build_corpus`] call
21
- /// notices its cached generation is stale and rebuilds. The MCP server is
22
- /// single-threaded stdio today, so the `Mutex` is uncontended in practice —
23
- /// it exists for forward compatibility with a future multi-session server.
24
- pub struct CorpusCache {
25
- generation: u64,
26
- built_generation: Option<u64>,
27
- corpus: Option<lexsim::Corpus>,
28
- doc_texts: Vec<String>,
29
- }
30
-
31
- impl CorpusCache {
32
- fn new() -> Self {
33
- CorpusCache {
34
- generation: 0,
35
- built_generation: None,
36
- corpus: None,
37
- doc_texts: Vec::new(),
38
- }
39
- }
40
-
41
- /// Invalidate the cached corpus. Called after any mutation to the
42
- /// underlying fragment store (`doc_save`, `doc_delete`, `doc_import`).
43
- pub fn increment_generation(&mut self) {
44
- self.generation += 1;
45
- }
46
-
47
- /// Return the cached corpus if it is still current for `doc_texts`,
48
- /// otherwise rebuild it from `doc_texts` and cache the result.
49
- ///
50
- /// `doc_texts` is the caller's current full set of fragment index texts,
51
- /// supplied fresh on every call (the cache does not own fragment
52
- /// storage) — only the expensive `lexsim::Corpus::build` step is skipped
53
- /// when the generation hasn't moved since the last build.
54
- pub fn get_or_build_corpus(&mut self, doc_texts: &[String]) -> &lexsim::Corpus {
55
- let stale = self.built_generation != Some(self.generation) || self.doc_texts != doc_texts;
56
- if stale {
57
- self.corpus = Some(lexsim::Corpus::build_weighted(doc_texts));
58
- self.doc_texts = doc_texts.to_vec();
59
- self.built_generation = Some(self.generation);
60
- }
61
- self.corpus
62
- .as_ref()
63
- .expect("corpus is always populated by the stale branch above")
64
- }
65
-
66
- /// Current generation counter (test/inspection hook).
67
- pub fn generation(&self) -> u64 {
68
- self.generation
69
- }
70
- }
71
-
72
- impl Default for CorpusCache {
73
- fn default() -> Self {
74
- Self::new()
75
- }
76
- }
77
-
78
- static DOC_CORPUS_CACHE: OnceLock<Mutex<CorpusCache>> = OnceLock::new();
79
-
80
- /// The process-wide document corpus cache, created lazily on first access.
81
- pub fn doc_corpus_cache() -> &'static Mutex<CorpusCache> {
82
- DOC_CORPUS_CACHE.get_or_init(|| Mutex::new(CorpusCache::new()))
83
- }
84
-
85
- #[cfg(test)]
86
- mod tests {
87
- use super::*;
88
-
89
- #[test]
90
- fn get_or_build_corpus_reuses_cache_when_generation_unchanged() {
91
- let mut cache = CorpusCache::new();
92
- let texts = vec!["alpha beta".to_string(), "gamma delta".to_string()];
93
- {
94
- let corpus = cache.get_or_build_corpus(&texts);
95
- assert_eq!(corpus.len(), 2);
96
- }
97
- assert_eq!(cache.built_generation, Some(0));
98
- // Second call with identical inputs and generation should not rebuild
99
- // (rebuild is observable only via built_generation staying put).
100
- let _ = cache.get_or_build_corpus(&texts);
101
- assert_eq!(cache.built_generation, Some(0));
102
- }
103
-
104
- #[test]
105
- fn increment_generation_forces_rebuild() {
106
- let mut cache = CorpusCache::new();
107
- let texts = vec!["alpha".to_string()];
108
- let _ = cache.get_or_build_corpus(&texts);
109
- assert_eq!(cache.built_generation, Some(0));
110
-
111
- cache.increment_generation();
112
- assert_eq!(cache.generation(), 1);
113
-
114
- let texts2 = vec!["alpha".to_string(), "beta".to_string()];
115
- let corpus = cache.get_or_build_corpus(&texts2);
116
- assert_eq!(corpus.len(), 2);
117
- assert_eq!(cache.built_generation, Some(1));
118
- }
119
-
120
- #[test]
121
- fn doc_corpus_cache_is_a_shared_singleton() {
122
- {
123
- let mut guard = doc_corpus_cache().lock().expect("cache mutex poisoned");
124
- guard.increment_generation();
125
- }
126
- let guard = doc_corpus_cache().lock().expect("cache mutex poisoned");
127
- assert!(guard.generation() >= 1);
128
- }
129
- }
package/src/lib.rs DELETED
@@ -1,5 +0,0 @@
1
- pub mod cli;
2
- pub mod context;
3
- pub mod mcp;
4
- pub mod setup;
5
- pub mod storage;
package/src/main.rs DELETED
@@ -1,157 +0,0 @@
1
- use std::io::{self, BufRead, Write};
2
- use std::sync::mpsc;
3
- use std::thread;
4
- use std::time::Duration;
5
-
6
- use handoff_mcp::mcp::protocol::process_line;
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
-
24
- fn main() {
25
- let args: Vec<String> = std::env::args().collect();
26
-
27
- if args.len() >= 2 {
28
- match args[1].as_str() {
29
- "setup" => {
30
- let check = args.iter().any(|a| a == "--check");
31
- let uninstall = args.iter().any(|a| a == "--uninstall");
32
- let mcp_json = args.iter().any(|a| a == "--mcp-json");
33
- let yes = args.iter().any(|a| a == "-y" || a == "--yes");
34
- let global = args.iter().any(|a| a == "--global");
35
- let force = args.iter().any(|a| a == "--force");
36
- if let Err(e) = handoff_mcp::setup::run_setup_with_opts(
37
- check, uninstall, mcp_json, yes, global, force,
38
- ) {
39
- eprintln!("Error: {e:#}");
40
- std::process::exit(1);
41
- }
42
- return;
43
- }
44
- "--version" | "-V" => {
45
- println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
46
- return;
47
- }
48
- "--help" | "-h" => {
49
- print_help();
50
- return;
51
- }
52
- other => {
53
- if handoff_mcp::cli::is_cli_command(other) {
54
- let cli_args: Vec<String> = args[1..].to_vec();
55
- // Handle --help for group
56
- if cli_args.len() >= 2 && (cli_args[1] == "--help" || cli_args[1] == "-h") {
57
- handoff_mcp::cli::print_group_help(&cli_args[0]);
58
- return;
59
- }
60
- let code = handoff_mcp::cli::run(&cli_args);
61
- std::process::exit(code);
62
- }
63
- eprintln!("Unknown command: {other}");
64
- eprintln!("Run `handoff-mcp --help` for usage.");
65
- std::process::exit(1);
66
- }
67
- }
68
- }
69
-
70
- eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
71
-
72
- let stdin = io::stdin();
73
- let mut stdout = io::stdout();
74
- let timeout = request_timeout();
75
-
76
- for line in stdin.lock().lines() {
77
- let line = match line {
78
- Ok(l) => l,
79
- Err(e) => {
80
- eprintln!("stdin read error: {e}");
81
- break;
82
- }
83
- };
84
-
85
- // Run this request on a dedicated worker thread so a slow handler
86
- // can't block the main loop forever: `recv_timeout` below bounds how
87
- // long we wait for a reply. The next line is only read after this
88
- // request's worker completes (or times out) — sequential processing
89
- // order is preserved; this is a fail-safe timeout, not parallelism.
90
- let (tx, rx) = mpsc::channel();
91
- let line_for_worker = line.clone();
92
- thread::spawn(move || {
93
- let result = process_line(&line_for_worker);
94
- let _ = tx.send(result);
95
- });
96
-
97
- match rx.recv_timeout(timeout) {
98
- Ok(Some(response)) => {
99
- if writeln!(stdout, "{response}").is_err() {
100
- break;
101
- }
102
- if stdout.flush().is_err() {
103
- break;
104
- }
105
- }
106
- Ok(None) => {
107
- // Notification: no response expected.
108
- }
109
- Err(mpsc::RecvTimeoutError::Timeout) => {
110
- eprintln!("Request timed out after {}s", timeout.as_secs());
111
- let id = serde_json::from_str::<serde_json::Value>(&line)
112
- .ok()
113
- .and_then(|req| req.get("id").cloned())
114
- .unwrap_or(serde_json::Value::Null);
115
- let error_response = serde_json::json!({
116
- "jsonrpc": "2.0",
117
- "id": id,
118
- "error": {
119
- "code": -32603,
120
- "message": format!("Request timed out after {}s", timeout.as_secs())
121
- }
122
- });
123
- if writeln!(stdout, "{error_response}").is_err() {
124
- break;
125
- }
126
- if stdout.flush().is_err() {
127
- break;
128
- }
129
- }
130
- Err(mpsc::RecvTimeoutError::Disconnected) => {
131
- eprintln!("Worker thread disconnected without a response");
132
- }
133
- }
134
- }
135
- }
136
-
137
- fn print_help() {
138
- handoff_mcp::cli::print_cli_help();
139
- println!(
140
- "
141
- SERVER MODE:
142
- handoff-mcp Start the MCP server (stdio transport)
143
-
144
- SETUP:
145
- handoff-mcp setup Install hooks + MCP server + CLAUDE.md template
146
- handoff-mcp setup --global Use ~/.claude/settings.json mcpServers (not .mcp.json)
147
- handoff-mcp setup --check Check if hooks, MCP, and CLAUDE.md are configured
148
- handoff-mcp setup --uninstall Remove handoff hooks and MCP entry
149
- handoff-mcp setup --mcp-json Add handoff MCP server entry only (non-interactive)
150
- handoff-mcp setup --force Replace existing CLAUDE.md handoff section with latest
151
- handoff-mcp setup -y Skip all confirmation prompts
152
-
153
- OPTIONS:
154
- -h, --help Print this help message
155
- -V, --version Print version"
156
- );
157
- }