specmem-hardwicksoftware 3.7.13 → 3.7.14

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.
@@ -47,7 +47,9 @@ function getSpecmemDir() {
47
47
  // Otherwise we're in src/config
48
48
  return path.resolve(currentDir, '..', '..');
49
49
  }
50
- const BOOTSTRAP_PATH = path.join(getSpecmemDir(), 'bootstrap.cjs');
50
+ // Prefer proxy for resilient MCP connections (auto-reconnect on crash)
51
+ const _proxyPath = path.join(getSpecmemDir(), 'mcp-proxy.cjs');
52
+ const BOOTSTRAP_PATH = fs.existsSync(_proxyPath) ? _proxyPath : path.join(getSpecmemDir(), 'bootstrap.cjs');
51
53
  const SOURCE_HOOKS_DIR = path.join(getSpecmemDir(), 'claude-hooks');
52
54
  const SOURCE_COMMANDS_DIR = path.join(getSpecmemDir(), 'commands');
53
55
  // ============================================================================
@@ -97,8 +97,11 @@ function getSpecmemRoot() {
97
97
  // 6. Last resort - return __dirname-based path even without bootstrap
98
98
  return fromThisFile;
99
99
  }
100
- // Find the actual bootstrap file (cjs or js)
100
+ // Find the MCP entry point prefer proxy for resilient connections
101
101
  function findBootstrapPath(root) {
102
+ // Proxy wraps bootstrap.cjs with auto-reconnect on crash
103
+ const proxy = path.join(root, 'mcp-proxy.cjs');
104
+ if (fs.existsSync(proxy)) return proxy;
102
105
  for (const name of ['bootstrap.cjs', 'bootstrap.js']) {
103
106
  const p = path.join(root, name);
104
107
  if (fs.existsSync(p)) return p;
package/mcp-proxy.cjs ADDED
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Proxy - Resilient stdio proxy for SpecMem MCP server
4
+ *
5
+ * Claude connects to this proxy via stdio. The proxy manages the actual
6
+ * MCP server (bootstrap.cjs) as a child process. If the server crashes
7
+ * or restarts, the proxy reconnects transparently — Claude never sees
8
+ * a disconnect.
9
+ *
10
+ * Protocol: MCP uses Content-Length framed JSON-RPC 2.0 over stdio.
11
+ */
12
+
13
+ const { spawn } = require('child_process');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ // Config
18
+ const BOOTSTRAP_PATH = path.join(__dirname, 'bootstrap.cjs');
19
+ const MAX_RESTART_DELAY = 10000; // 10s max backoff
20
+ const INITIAL_RESTART_DELAY = 500; // 500ms first retry
21
+ const MAX_QUEUE_SIZE = 200;
22
+ const HEARTBEAT_INTERVAL = 30000; // 30s keepalive pings
23
+
24
+ // State
25
+ let child = null;
26
+ let childReady = false;
27
+ let pendingQueue = []; // Messages queued during reconnect
28
+ let restartDelay = INITIAL_RESTART_DELAY;
29
+ let restartCount = 0;
30
+ let lastInitializeRequest = null; // Cache the initialize request for re-init
31
+ let lastInitializeResponse = null;
32
+ let initializeId = null;
33
+ let shuttingDown = false;
34
+ let heartbeatTimer = null;
35
+ let childStdoutBuffer = '';
36
+ let stdinBuffer = '';
37
+
38
+ function log(msg) {
39
+ try {
40
+ fs.appendFileSync('/tmp/specmem-proxy.log',
41
+ `[${new Date().toISOString()}] ${msg}\n`);
42
+ } catch {}
43
+ }
44
+
45
+ log(`Proxy starting. PID=${process.pid} BOOTSTRAP=${BOOTSTRAP_PATH}`);
46
+ log(`ENV: PROJECT_PATH=${process.env.SPECMEM_PROJECT_PATH}`);
47
+
48
+ // ============================================================================
49
+ // Content-Length framed message parser
50
+ // ============================================================================
51
+ function parseMessages(buffer) {
52
+ const messages = [];
53
+ let remaining = buffer;
54
+
55
+ while (remaining.length > 0) {
56
+ // Look for Content-Length header
57
+ const headerEnd = remaining.indexOf('\r\n\r\n');
58
+ if (headerEnd === -1) break;
59
+
60
+ const header = remaining.substring(0, headerEnd);
61
+ const match = header.match(/Content-Length:\s*(\d+)/i);
62
+ if (!match) {
63
+ // Skip malformed data
64
+ remaining = remaining.substring(headerEnd + 4);
65
+ continue;
66
+ }
67
+
68
+ const contentLength = parseInt(match[1], 10);
69
+ const bodyStart = headerEnd + 4;
70
+ const bodyEnd = bodyStart + contentLength;
71
+
72
+ if (remaining.length < bodyEnd) {
73
+ break; // Incomplete message, wait for more data
74
+ }
75
+
76
+ const body = remaining.substring(bodyStart, bodyEnd);
77
+ remaining = remaining.substring(bodyEnd);
78
+
79
+ try {
80
+ messages.push(JSON.parse(body));
81
+ } catch (e) {
82
+ log(`Parse error: ${e.message} body=${body.substring(0, 100)}`);
83
+ }
84
+ }
85
+
86
+ return { messages, remaining };
87
+ }
88
+
89
+ function frameMessage(obj) {
90
+ const body = JSON.stringify(obj);
91
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
92
+ }
93
+
94
+ // ============================================================================
95
+ // Send message to Claude (stdout)
96
+ // ============================================================================
97
+ function sendToClient(msg) {
98
+ try {
99
+ process.stdout.write(frameMessage(msg));
100
+ } catch (e) {
101
+ log(`stdout write error: ${e.message}`);
102
+ }
103
+ }
104
+
105
+ // ============================================================================
106
+ // Send message to MCP server (child stdin)
107
+ // ============================================================================
108
+ function sendToServer(msg) {
109
+ if (!child || !childReady || child.killed) {
110
+ // Queue it
111
+ if (pendingQueue.length < MAX_QUEUE_SIZE) {
112
+ pendingQueue.push(msg);
113
+ log(`Queued message (${pendingQueue.length} pending): ${msg.method || msg.id || '?'}`);
114
+ } else {
115
+ log(`Queue full, dropping message: ${msg.method || msg.id || '?'}`);
116
+ }
117
+ return;
118
+ }
119
+
120
+ try {
121
+ child.stdin.write(frameMessage(msg));
122
+ } catch (e) {
123
+ log(`child stdin write error: ${e.message}`);
124
+ pendingQueue.push(msg);
125
+ }
126
+ }
127
+
128
+ // ============================================================================
129
+ // Flush queued messages to server
130
+ // ============================================================================
131
+ function flushQueue() {
132
+ if (pendingQueue.length === 0) return;
133
+ log(`Flushing ${pendingQueue.length} queued messages to server`);
134
+ const queue = [...pendingQueue];
135
+ pendingQueue = [];
136
+ for (const msg of queue) {
137
+ sendToServer(msg);
138
+ }
139
+ }
140
+
141
+ // ============================================================================
142
+ // Spawn/restart the MCP server
143
+ // ============================================================================
144
+ function spawnServer() {
145
+ if (shuttingDown) return;
146
+ if (child && !child.killed) {
147
+ try { child.kill('SIGTERM'); } catch {}
148
+ }
149
+
150
+ childReady = false;
151
+ childStdoutBuffer = '';
152
+
153
+ const args = process.argv.slice(2); // Pass through any args
154
+ const env = { ...process.env };
155
+
156
+ log(`Spawning server: node ${BOOTSTRAP_PATH} ${args.join(' ')}`);
157
+
158
+ child = spawn('node', ['--max-old-space-size=250', BOOTSTRAP_PATH, ...args], {
159
+ env,
160
+ stdio: ['pipe', 'pipe', 'pipe'],
161
+ cwd: process.env.SPECMEM_PROJECT_PATH || process.cwd()
162
+ });
163
+
164
+ child.stderr.on('data', (data) => {
165
+ // Forward stderr (logs) to our stderr
166
+ process.stderr.write(data);
167
+ });
168
+
169
+ child.stdout.on('data', (data) => {
170
+ childStdoutBuffer += data.toString();
171
+
172
+ const { messages, remaining } = parseMessages(childStdoutBuffer);
173
+ childStdoutBuffer = remaining;
174
+
175
+ for (const msg of messages) {
176
+ // If this is the response to initialize, cache it and mark ready
177
+ if (msg.id !== undefined && msg.id === initializeId && msg.result) {
178
+ lastInitializeResponse = msg;
179
+ childReady = true;
180
+ restartDelay = INITIAL_RESTART_DELAY;
181
+ restartCount = 0;
182
+ log(`Server initialized (id=${msg.id}). Flushing queue.`);
183
+
184
+ // If this is a RE-init (not the first), don't send the response
185
+ // to Claude — Claude already has the init response from first time
186
+ if (restartCount > 0 || lastInitializeResponse) {
187
+ // Still send it on first init
188
+ }
189
+ sendToClient(msg);
190
+ flushQueue();
191
+ startHeartbeat();
192
+ continue;
193
+ }
194
+
195
+ // Forward everything else to Claude
196
+ sendToClient(msg);
197
+ }
198
+ });
199
+
200
+ child.on('error', (err) => {
201
+ log(`Server process error: ${err.message}`);
202
+ scheduleRestart();
203
+ });
204
+
205
+ child.on('exit', (code, signal) => {
206
+ log(`Server exited: code=${code} signal=${signal}`);
207
+ childReady = false;
208
+ stopHeartbeat();
209
+
210
+ if (!shuttingDown) {
211
+ scheduleRestart();
212
+ }
213
+ });
214
+
215
+ // If we have a cached initialize request, re-send it
216
+ if (lastInitializeRequest && restartCount > 0) {
217
+ log(`Re-sending initialize request (restart #${restartCount})`);
218
+ setTimeout(() => {
219
+ if (child && !child.killed) {
220
+ try {
221
+ child.stdin.write(frameMessage(lastInitializeRequest));
222
+ // Also send initialized notification
223
+ setTimeout(() => {
224
+ if (child && !child.killed) {
225
+ try {
226
+ child.stdin.write(frameMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }));
227
+ } catch {}
228
+ }
229
+ }, 100);
230
+ } catch (e) {
231
+ log(`Re-init write error: ${e.message}`);
232
+ }
233
+ }
234
+ }, 200);
235
+ }
236
+
237
+ restartCount++;
238
+ }
239
+
240
+ function scheduleRestart() {
241
+ if (shuttingDown) return;
242
+ log(`Scheduling restart in ${restartDelay}ms (restart #${restartCount})`);
243
+ setTimeout(() => {
244
+ spawnServer();
245
+ }, restartDelay);
246
+ restartDelay = Math.min(restartDelay * 2, MAX_RESTART_DELAY);
247
+ }
248
+
249
+ // ============================================================================
250
+ // Heartbeat — detect dead server
251
+ // ============================================================================
252
+ function startHeartbeat() {
253
+ stopHeartbeat();
254
+ heartbeatTimer = setInterval(() => {
255
+ if (!child || child.killed || !childReady) return;
256
+ // Send a ping (list tools) to keep connection alive
257
+ // MCP doesn't have a ping method, but we can use this to detect dead pipes
258
+ try {
259
+ child.stdin.write(''); // Zero-byte write to test pipe
260
+ } catch (e) {
261
+ log(`Heartbeat detected dead pipe: ${e.message}`);
262
+ childReady = false;
263
+ try { child.kill('SIGTERM'); } catch {}
264
+ }
265
+ }, HEARTBEAT_INTERVAL);
266
+ }
267
+
268
+ function stopHeartbeat() {
269
+ if (heartbeatTimer) {
270
+ clearInterval(heartbeatTimer);
271
+ heartbeatTimer = null;
272
+ }
273
+ }
274
+
275
+ // ============================================================================
276
+ // Handle stdin from Claude
277
+ // ============================================================================
278
+ process.stdin.on('data', (data) => {
279
+ stdinBuffer += data.toString();
280
+
281
+ const { messages, remaining } = parseMessages(stdinBuffer);
282
+ stdinBuffer = remaining;
283
+
284
+ for (const msg of messages) {
285
+ // Cache the initialize request so we can re-send on restart
286
+ if (msg.method === 'initialize') {
287
+ lastInitializeRequest = msg;
288
+ initializeId = msg.id;
289
+ log(`Got initialize request (id=${msg.id})`);
290
+ }
291
+
292
+ // Cache initialized notification
293
+ if (msg.method === 'notifications/initialized') {
294
+ log('Got initialized notification');
295
+ }
296
+
297
+ sendToServer(msg);
298
+ }
299
+ });
300
+
301
+ process.stdin.on('end', () => {
302
+ log('stdin closed (Claude disconnected)');
303
+ shutdown();
304
+ });
305
+
306
+ process.stdin.on('error', (err) => {
307
+ log(`stdin error: ${err.message}`);
308
+ shutdown();
309
+ });
310
+
311
+ // ============================================================================
312
+ // Graceful shutdown
313
+ // ============================================================================
314
+ function shutdown() {
315
+ if (shuttingDown) return;
316
+ shuttingDown = true;
317
+ log('Proxy shutting down');
318
+ stopHeartbeat();
319
+ if (child && !child.killed) {
320
+ child.kill('SIGTERM');
321
+ setTimeout(() => {
322
+ try { child.kill('SIGKILL'); } catch {}
323
+ process.exit(0);
324
+ }, 3000);
325
+ } else {
326
+ process.exit(0);
327
+ }
328
+ }
329
+
330
+ process.on('SIGTERM', shutdown);
331
+ process.on('SIGINT', shutdown);
332
+
333
+ // ============================================================================
334
+ // Start
335
+ // ============================================================================
336
+ spawnServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specmem-hardwicksoftware",
3
- "version": "3.7.13",
3
+ "version": "3.7.14",
4
4
  "type": "module",
5
5
  "description": "Persistent memory system for coding sessions - semantic search with pgvector, token compression, team coordination, file watching. Needs root: installs system-wide hooks, manages docker/PostgreSQL, writes global configs, handles screen sessions. justcalljon.pro",
6
6
  "main": "dist/index.js",
@@ -128,6 +128,7 @@
128
128
  "embedding-sandbox/",
129
129
  "legal/",
130
130
  "bootstrap.cjs",
131
+ "mcp-proxy.cjs",
131
132
  "specmem-health.cjs",
132
133
  "specmem.env",
133
134
  "LICENSE.md",
@@ -6511,12 +6511,16 @@ async function runAutoSetup(projectPath) {
6511
6511
 
6512
6512
  // Configure MCP server for this project
6513
6513
  claudeJson.projects[projectPath].mcpServers = claudeJson.projects[projectPath].mcpServers || {};
6514
+ // Prefer mcp-proxy.cjs for resilient connections (auto-reconnect)
6515
+ const mcpEntry = fs.existsSync(path.join(specmemPkg, 'mcp-proxy.cjs'))
6516
+ ? path.join(specmemPkg, 'mcp-proxy.cjs')
6517
+ : path.join(specmemPkg, 'bootstrap.cjs');
6514
6518
  claudeJson.projects[projectPath].mcpServers.specmem = {
6515
6519
  type: "stdio",
6516
6520
  command: "node",
6517
6521
  args: [
6518
6522
  "--max-old-space-size=250",
6519
- path.join(specmemPkg, 'bootstrap.cjs')
6523
+ mcpEntry
6520
6524
  ],
6521
6525
  env: {
6522
6526
  HOME: os.homedir(),