memorix 1.2.2 → 1.2.3

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 (139) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +34 -18
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/index.js +17 -8
  6. package/dist/index.js.map +1 -1
  7. package/dist/maintenance-runner.js.map +1 -1
  8. package/dist/memcode-runtime/CHANGELOG.md +7 -0
  9. package/dist/sdk.js +17 -8
  10. package/dist/sdk.js.map +1 -1
  11. package/docs/DESIGN_DECISIONS.md +357 -357
  12. package/docs/dev-log/progress.txt +18 -8
  13. package/package.json +1 -1
  14. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  15. package/src/audit/index.ts +156 -156
  16. package/src/cli/commands/audit-list.ts +89 -89
  17. package/src/cli/commands/background.ts +659 -659
  18. package/src/cli/commands/formation.ts +48 -48
  19. package/src/cli/commands/git-hook-install.ts +111 -111
  20. package/src/cli/commands/handoff.ts +54 -54
  21. package/src/cli/commands/hooks-status.ts +63 -63
  22. package/src/cli/commands/ingest-commit.ts +153 -153
  23. package/src/cli/commands/ingest-image.ts +66 -66
  24. package/src/cli/commands/ingest-log.ts +180 -180
  25. package/src/cli/commands/ingest.ts +44 -44
  26. package/src/cli/commands/integrate-shared.ts +15 -15
  27. package/src/cli/commands/lock.ts +82 -82
  28. package/src/cli/commands/message.ts +104 -104
  29. package/src/cli/commands/poll.ts +58 -58
  30. package/src/cli/commands/purge-all-memory.ts +85 -85
  31. package/src/cli/commands/purge-project-memory.ts +83 -83
  32. package/src/cli/commands/reasoning.ts +118 -118
  33. package/src/cli/commands/serve-shared.ts +118 -118
  34. package/src/cli/commands/session.ts +15 -7
  35. package/src/cli/commands/skills.ts +114 -114
  36. package/src/cli/commands/task.ts +167 -167
  37. package/src/cli/commands/transfer.ts +47 -47
  38. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  39. package/src/cli/tui/ChatView.tsx +234 -234
  40. package/src/cli/tui/CommandBar.tsx +312 -312
  41. package/src/cli/tui/ContextRail.tsx +118 -118
  42. package/src/cli/tui/HeaderBar.tsx +72 -72
  43. package/src/cli/tui/LogoBanner.tsx +51 -51
  44. package/src/cli/tui/Sidebar.tsx +179 -179
  45. package/src/cli/tui/index.ts +41 -41
  46. package/src/cli/tui/markdown-render.tsx +371 -371
  47. package/src/cli/tui/session-service.ts +3 -2
  48. package/src/cli/tui/use-mouse.ts +157 -157
  49. package/src/cli/tui/useNavigation.ts +56 -56
  50. package/src/cli/update-checker.ts +211 -211
  51. package/src/cli/version.ts +7 -7
  52. package/src/cli/workbench.ts +1 -1
  53. package/src/compact/token-budget.ts +74 -74
  54. package/src/dashboard/project-classification.ts +64 -64
  55. package/src/embedding/fastembed-provider.ts +142 -142
  56. package/src/embedding/transformers-provider.ts +111 -111
  57. package/src/git/extractor.ts +209 -209
  58. package/src/git/hooks-path.ts +85 -85
  59. package/src/hooks/pattern-detector.ts +173 -173
  60. package/src/hooks/significance-filter.ts +250 -250
  61. package/src/llm/memory-manager.ts +328 -328
  62. package/src/llm/provider.ts +885 -885
  63. package/src/llm/quality.ts +248 -248
  64. package/src/memory/attribution-guard.ts +249 -249
  65. package/src/memory/disclosure-policy.ts +135 -135
  66. package/src/memory/entity-extractor.ts +197 -197
  67. package/src/memory/formation/evaluate.ts +217 -217
  68. package/src/memory/formation/extract.ts +361 -361
  69. package/src/memory/formation/index.ts +417 -417
  70. package/src/memory/formation/resolve.ts +344 -344
  71. package/src/memory/formation/types.ts +315 -315
  72. package/src/memory/freshness.ts +122 -122
  73. package/src/memory/graph.ts +197 -197
  74. package/src/memory/refs.ts +94 -94
  75. package/src/memory/secret-filter.ts +79 -79
  76. package/src/memory/session.ts +24 -9
  77. package/src/multimodal/image-loader.ts +143 -143
  78. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  79. package/src/orchestrate/adapters/claude.ts +111 -111
  80. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  81. package/src/orchestrate/adapters/codex.ts +41 -41
  82. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  83. package/src/orchestrate/adapters/gemini.ts +42 -42
  84. package/src/orchestrate/adapters/index.ts +73 -73
  85. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  86. package/src/orchestrate/adapters/opencode.ts +47 -47
  87. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  88. package/src/orchestrate/adapters/types.ts +77 -77
  89. package/src/orchestrate/capability-router.ts +284 -284
  90. package/src/orchestrate/context-compact.ts +188 -188
  91. package/src/orchestrate/cost-tracker.ts +219 -219
  92. package/src/orchestrate/error-recovery.ts +191 -191
  93. package/src/orchestrate/evidence.ts +140 -140
  94. package/src/orchestrate/ledger.ts +110 -110
  95. package/src/orchestrate/memorix-bridge.ts +343 -343
  96. package/src/orchestrate/output-budget.ts +80 -80
  97. package/src/orchestrate/permission.ts +152 -152
  98. package/src/orchestrate/pipeline-trace.ts +131 -131
  99. package/src/orchestrate/prompt-builder.ts +155 -155
  100. package/src/orchestrate/ring-buffer.ts +37 -37
  101. package/src/orchestrate/task-graph.ts +389 -389
  102. package/src/orchestrate/worktree.ts +232 -232
  103. package/src/project/aliases.ts +374 -374
  104. package/src/project/detector.ts +268 -268
  105. package/src/rules/adapters/claude-code.ts +99 -99
  106. package/src/rules/adapters/codex.ts +97 -97
  107. package/src/rules/adapters/copilot.ts +124 -124
  108. package/src/rules/adapters/cursor.ts +114 -114
  109. package/src/rules/adapters/kiro.ts +126 -126
  110. package/src/rules/adapters/trae.ts +56 -56
  111. package/src/rules/adapters/windsurf.ts +83 -83
  112. package/src/rules/syncer.ts +235 -235
  113. package/src/sdk.ts +299 -299
  114. package/src/search/intent-detector.ts +289 -289
  115. package/src/search/query-expansion.ts +52 -52
  116. package/src/server/formation-timeout.ts +27 -27
  117. package/src/server.ts +7 -2
  118. package/src/skills/mini-skills.ts +386 -386
  119. package/src/store/chat-store.ts +119 -119
  120. package/src/store/graph-store.ts +249 -249
  121. package/src/store/mini-skill-store.ts +349 -349
  122. package/src/store/persistence-json.ts +212 -212
  123. package/src/store/persistence.ts +291 -291
  124. package/src/store/project-affinity.ts +195 -195
  125. package/src/team/event-bus.ts +76 -76
  126. package/src/team/file-locks.ts +173 -173
  127. package/src/team/handoff.ts +161 -161
  128. package/src/team/messages.ts +203 -203
  129. package/src/team/poll.ts +132 -132
  130. package/src/team/tasks.ts +211 -211
  131. package/src/workspace/mcp-adapters/codex.ts +191 -191
  132. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  133. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  134. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  135. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  136. package/src/workspace/mcp-adapters/trae.ts +134 -134
  137. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  138. package/src/workspace/sanitizer.ts +60 -60
  139. package/src/workspace/workflow-sync.ts +131 -131
@@ -1,659 +1,659 @@
1
- /**
2
- * memorix background — Manage the Memorix Control Plane as a background service
3
- *
4
- * Subcommands:
5
- * memorix background start — Launch control plane in background (HTTP 3211)
6
- * memorix background stop — Stop the background control plane
7
- * memorix background status — Show running state, PID, port, health
8
- * memorix background restart — Stop + start
9
- * memorix background logs — Tail the background service log
10
- *
11
- * State is persisted in ~/.memorix/background.json so status survives shell restarts.
12
- * Logs are written to ~/.memorix/background.log.
13
- *
14
- * Mode distinction:
15
- * Quick Mode = stdio / single project / zero friction
16
- * Control Plane = HTTP / shared MCP / multi-session / live dashboard
17
- * Background = Control Plane's productized run mode (no terminal babysitting)
18
- */
19
-
20
- import { defineCommand } from 'citty';
21
- import * as fs from 'node:fs';
22
- import { spawn, execSync } from 'node:child_process';
23
- import { randomBytes } from 'node:crypto';
24
-
25
- // ============================================================
26
- // Paths & Types
27
- // ============================================================
28
-
29
- interface BackgroundState {
30
- pid: number;
31
- port: number;
32
- startedAt: string; // ISO timestamp
33
- logFile: string;
34
- /** Unique instance token — prevents PID-reuse misidentification */
35
- instanceToken: string;
36
- /** Shell cwd at start time (informational only, NOT used as daemon anchor) */
37
- startCwd?: string;
38
- }
39
-
40
- function getMemorixDir(): string {
41
- const home = process.env.HOME || process.env.USERPROFILE || '';
42
- // Use join-style path to ensure native separators on Windows
43
- return home.replace(/\\/g, '/') + '/.memorix';
44
- }
45
-
46
- function normalizePath(p: string): string {
47
- // Normalize to OS-native separators for display
48
- return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
49
- }
50
-
51
- function getStateFilePath(): string {
52
- return getMemorixDir() + '/background.json';
53
- }
54
-
55
- function getLogFilePath(): string {
56
- return getMemorixDir() + '/background.log';
57
- }
58
-
59
- // ============================================================
60
- // State persistence
61
- // ============================================================
62
-
63
- function loadState(): BackgroundState | null {
64
- try {
65
- const data = fs.readFileSync(getStateFilePath(), 'utf-8');
66
- return JSON.parse(data) as BackgroundState;
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- function saveState(state: BackgroundState): void {
73
- const dir = getMemorixDir();
74
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
75
- fs.writeFileSync(getStateFilePath(), JSON.stringify(state, null, 2));
76
- }
77
-
78
- function clearState(): void {
79
- try {
80
- fs.unlinkSync(getStateFilePath());
81
- } catch { /* already gone */ }
82
- }
83
-
84
- // ============================================================
85
- // Process utilities (cross-platform)
86
- // ============================================================
87
-
88
- function isProcessRunning(pid: number): boolean {
89
- try {
90
- process.kill(pid, 0); // Signal 0 = existence check, no actual signal sent
91
- return true;
92
- } catch {
93
- return false;
94
- }
95
- }
96
-
97
- function killProcess(pid: number): boolean {
98
- try {
99
- // On Windows, process.kill sends SIGTERM which works for Node child processes
100
- process.kill(pid);
101
- return true;
102
- } catch {
103
- return false;
104
- }
105
- }
106
-
107
- async function healthCheck(port: number, timeoutMs = 3000): Promise<{ ok: boolean; data?: any; error?: string }> {
108
- try {
109
- const controller = new AbortController();
110
- const timer = setTimeout(() => controller.abort(), timeoutMs);
111
- // Use /health endpoint — lightweight, no TeamStore or heavy init required.
112
- // Responds immediately once the HTTP server has bound the port.
113
- const res = await fetch(`http://127.0.0.1:${port}/health`, {
114
- signal: controller.signal,
115
- });
116
- clearTimeout(timer);
117
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
118
- const data = await res.json();
119
- return { ok: true, data };
120
- } catch (err) {
121
- return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
122
- }
123
- }
124
-
125
- async function isPortInUse(port: number): Promise<boolean> {
126
- const health = await healthCheck(port, 1500);
127
- return health.ok;
128
- }
129
-
130
- // ============================================================
131
- // Subcommands
132
- // ============================================================
133
-
134
- export async function doStart(port: number): Promise<void> {
135
- // 1. Check if already running — validate both PID existence AND HTTP health
136
- const state = loadState();
137
- if (state) {
138
- if (isProcessRunning(state.pid)) {
139
- const health = await healthCheck(state.port, 2000);
140
- if (health.ok) {
141
- console.log(`[OK] Control plane is already running (PID ${state.pid}, port ${state.port})`);
142
- console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
143
- console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
144
- return;
145
- }
146
- // PID alive but HTTP not responding — either still starting or stale
147
- // Give it one more chance with a longer timeout (5s)
148
- const retry = await healthCheck(state.port, 5000);
149
- if (retry.ok) {
150
- console.log(`[OK] Control plane is already running (PID ${state.pid}, port ${state.port})`);
151
- console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
152
- console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
153
- return;
154
- }
155
- // Stale or PID-reused — kill and auto-restart
156
- console.log(`[WARN] Stale process ${state.pid} detected (PID alive but port ${state.port} not responding), cleaning up...`);
157
- killProcess(state.pid);
158
- clearState();
159
- // Fall through to auto-restart below
160
- } else {
161
- // PID dead — clean up stale state and auto-restart
162
- console.log(`[WARN] Previous process (PID ${state.pid}) is dead. Cleaning up stale state...`);
163
- clearState();
164
- // Fall through to auto-restart below
165
- }
166
- } else {
167
- // No background.json — check heartbeat for crash evidence
168
- try {
169
- const heartbeatPath = getMemorixDir() + '/background.heartbeat';
170
- const hbData = fs.readFileSync(heartbeatPath, 'utf-8');
171
- const hb = JSON.parse(hbData);
172
- const age = Date.now() - hb.heartbeatAt;
173
- if (age < 120_000) { // heartbeat less than 2 minutes old
174
- console.log(`[WARN] Recent heartbeat found (PID ${hb.pid}, ${Math.round(age / 1000)}s ago) but no background.json.`);
175
- console.log(' The control plane likely crashed. Auto-restarting...');
176
- }
177
- try { fs.unlinkSync(heartbeatPath); } catch { /* ok */ }
178
- } catch { /* no heartbeat — first run or clean shutdown */ }
179
- }
180
-
181
- // 2. Check if port is already taken by another process
182
- if (await isPortInUse(port)) {
183
- // Port is occupied but no background.json — this is an unmanaged foreground process
184
- console.log('');
185
- console.log(`[WARN] Port ${port} is already serving a Memorix control plane, but it is NOT managed by background mode.`);
186
- console.log('');
187
- console.log(' This is likely a foreground "memorix serve-http" instance.');
188
- console.log(' To switch to background mode:');
189
- console.log(' 1. Stop the foreground instance (Ctrl+C in its terminal)');
190
- console.log(' 2. Run "memorix background start"');
191
- console.log('');
192
- console.log(` Dashboard: http://127.0.0.1:${port}/`);
193
- console.log(` MCP: http://127.0.0.1:${port}/mcp`);
194
- return;
195
- }
196
-
197
- // 3. Clean up stale readiness file from previous run
198
- try {
199
- const readyFile = getMemorixDir() + '/background.ready';
200
- if (fs.existsSync(readyFile)) fs.unlinkSync(readyFile);
201
- } catch { /* ignore */ }
202
-
203
- // 4. Prepare log file
204
- const logFile = getLogFilePath();
205
- const dir = getMemorixDir();
206
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
207
-
208
- // Rotate log: keep last run's log as .log.prev
209
- if (fs.existsSync(logFile)) {
210
- try { fs.renameSync(logFile, logFile + '.prev'); } catch { /* ok */ }
211
- }
212
-
213
- const logFd = fs.openSync(logFile, 'a');
214
-
215
- // 5. Find the memorix CLI entry point
216
- // We need to spawn `node <cli-entry> serve-http --port <port>`
217
- // The entry point is the same binary that's running now
218
- const cliEntry = process.argv[1]; // e.g., dist/cli/index.js or node_modules/.bin/memorix
219
-
220
- // 6. Spawn detached process
221
- // On Windows, detached:true + stdio:['ignore', fd, fd] + unref() is the correct
222
- // pattern. The child gets its own console and survives the parent terminal closing.
223
- // Note: Node.js spawn does NOT support a 'flags' option — that was a dead code path.
224
- const child = spawn(process.execPath, [cliEntry, 'serve-http', '--port', String(port)], {
225
- detached: true,
226
- stdio: ['ignore', logFd, logFd],
227
- env: { ...process.env },
228
- cwd: process.cwd(),
229
- windowsHide: true,
230
- });
231
-
232
- child.unref();
233
- fs.closeSync(logFd);
234
-
235
- const pid = child.pid;
236
- if (!pid) {
237
- console.error('[ERROR] Failed to spawn background process');
238
- process.exitCode = 1;
239
- return;
240
- }
241
-
242
- // 7. Generate instance token and save state (synchronous — no yield point before output)
243
- const instanceToken = randomBytes(8).toString('hex');
244
- saveState({
245
- pid,
246
- port,
247
- startedAt: new Date().toISOString(),
248
- logFile,
249
- instanceToken,
250
- startCwd: process.cwd(),
251
- });
252
-
253
- // 8. Print essential info immediately
254
- // Use stderr for the critical startup line — stderr is ALWAYS unbuffered/synchronous,
255
- // even when stdout is a pipe or in a non-TTY environment.
256
- const startMsg = [
257
- '',
258
- 'Starting Memorix Control Plane in background...',
259
- '',
260
- ` PID: ${pid}`,
261
- ` Port: ${port}`,
262
- ` Dashboard: http://127.0.0.1:${port}/`,
263
- ` MCP: http://127.0.0.1:${port}/mcp`,
264
- ` Logs: ${normalizePath(logFile)}`,
265
- '',
266
- ].join('\n');
267
- process.stderr.write(startMsg + '\n');
268
-
269
- // 9. Wait for readiness — ALWAYS, even in non-interactive mode.
270
- // The /health endpoint responds immediately once the HTTP server has bound the port,
271
- // so this typically completes in 1-3 seconds. Non-interactive callers (AI agents)
272
- // need the server to be actually ready before they try to connect.
273
- // Timeout: 30 seconds (large projects may need time to reindex before listen()).
274
- let healthy = false;
275
- for (let i = 0; i < 60; i++) {
276
- await new Promise(r => setTimeout(r, 500));
277
- const check = await healthCheck(port, 2000);
278
- if (check.ok) {
279
- healthy = true;
280
- break;
281
- }
282
- // Check if process died
283
- if (!isProcessRunning(pid)) {
284
- console.error('[ERROR] Background process exited unexpectedly.');
285
- console.error(` Check logs: ${normalizePath(logFile)}`);
286
- clearState();
287
- process.exitCode = 1;
288
- return;
289
- }
290
- }
291
-
292
- if (healthy) {
293
- process.stderr.write('[OK] Control plane is running and healthy.\n');
294
- } else {
295
- process.stderr.write('[WARN] Health check timed out — service may still be initializing.\n');
296
- process.stderr.write(' Check later: memorix background status\n');
297
- }
298
-
299
- const footer = [
300
- '',
301
- ' Quick Mode = stdio / single project / zero friction',
302
- ' Control Plane = HTTP / shared MCP / multi-session / live dashboard',
303
- ' Background = Control Plane running as a local service',
304
- '',
305
- ' Stop with: memorix background stop',
306
- ].join('\n');
307
- process.stderr.write(footer + '\n');
308
- }
309
-
310
- export async function doStop(): Promise<void> {
311
- const state = loadState();
312
-
313
- if (!state) {
314
- console.log('No background control plane is registered.');
315
- return;
316
- }
317
-
318
- if (!isProcessRunning(state.pid)) {
319
- console.log(`Background process (PID ${state.pid}) is not running. Cleaning up state.`);
320
- clearState();
321
- // Also clean up readiness file
322
- try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
323
- return;
324
- }
325
-
326
- // Validate that the PID is actually our Memorix process — check /health
327
- const health = await healthCheck(state.port, 2000);
328
- if (!health.ok) {
329
- // PID alive but port not responding — likely PID-reused or process stuck
330
- console.log(`[WARN] PID ${state.pid} is alive but port ${state.port} is not responding.`);
331
- console.log(' This may be a PID-reused unrelated process. Force-killing and cleaning up stale state.');
332
- try {
333
- if (process.platform === 'win32') {
334
- execSync(`taskkill /F /PID ${state.pid}`, { stdio: 'ignore' });
335
- } else {
336
- process.kill(state.pid, 'SIGKILL');
337
- }
338
- } catch { /* best effort */ }
339
- clearState();
340
- try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
341
- return;
342
- }
343
-
344
- // Verify PID matches — if /health returns a different PID, it's a different process
345
- if (health.data?.pid && health.data.pid !== state.pid) {
346
- console.log(`[WARN] PID mismatch: background.json has ${state.pid}, but /health reports ${health.data.pid}.`);
347
- console.log(' The Memorix process was likely restarted. Updating state...');
348
- // Don't kill — this is a valid Memorix, just with a different PID than we expected
349
- // (e.g., process was restarted manually)
350
- clearState();
351
- console.log(' Run "memorix background start" to register the current instance.');
352
- return;
353
- }
354
-
355
- console.log(`Stopping control plane (PID ${state.pid}, port ${state.port})...`);
356
-
357
- // Try graceful HTTP shutdown first
358
- let graceful = false;
359
- try {
360
- // Send SIGTERM — the serve-http handler has a graceful shutdown handler
361
- killProcess(state.pid);
362
- // Wait up to 5 seconds for process to exit
363
- for (let i = 0; i < 10; i++) {
364
- await new Promise(r => setTimeout(r, 500));
365
- if (!isProcessRunning(state.pid)) {
366
- graceful = true;
367
- break;
368
- }
369
- }
370
- } catch { /* process may already be gone */ }
371
-
372
- if (!graceful && isProcessRunning(state.pid)) {
373
- // Force kill on Windows
374
- try {
375
- if (process.platform === 'win32') {
376
- execSync(`taskkill /F /PID ${state.pid}`, { stdio: 'ignore' });
377
- } else {
378
- process.kill(state.pid, 'SIGKILL');
379
- }
380
- } catch { /* best effort */ }
381
- }
382
-
383
- clearState();
384
- // Clean up readiness file
385
- try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
386
- console.log('[OK] Control plane stopped.');
387
- }
388
-
389
- async function doStatus(): Promise<void> {
390
- const state = loadState();
391
-
392
- if (!state) {
393
- // No background.json — but check if port has an unmanaged foreground instance
394
- const portHealth = await healthCheck(3211, 2000);
395
- if (portHealth.ok) {
396
- console.log('');
397
- console.log('No background control plane is registered,');
398
- console.log('but a Memorix instance IS running on port 3211 (likely a foreground "memorix serve-http").');
399
- console.log('');
400
- console.log(` Dashboard: http://127.0.0.1:3211/`);
401
- console.log(` MCP: http://127.0.0.1:3211/mcp`);
402
- if (portHealth.data) {
403
- const d = portHealth.data;
404
- console.log('');
405
- console.log(' Health:');
406
- console.log(` PID: ${d.pid ?? 'unknown'}`);
407
- console.log(` Uptime: ${d.uptime ?? 0}s`);
408
- }
409
- console.log('');
410
- console.log(' To switch to background mode:');
411
- console.log(' 1. Stop the foreground instance (Ctrl+C in its terminal)');
412
- console.log(' 2. Run "memorix background start"');
413
- console.log('');
414
- } else {
415
- console.log('No background control plane is registered.');
416
- console.log('');
417
- console.log('Start one with: memorix background start');
418
- }
419
- return;
420
- }
421
-
422
- const running = isProcessRunning(state.pid);
423
-
424
- // Always do HTTP health check — this is the ground truth, not just PID existence
425
- const health = running ? await healthCheck(state.port) : { ok: false, error: 'Process not running' };
426
-
427
- // PID reuse detection: PID alive but /health fails or returns different PID
428
- const pidMismatch = health.ok && health.data?.pid && health.data.pid !== state.pid;
429
- const probablyReused = running && !health.ok;
430
-
431
- console.log('');
432
- console.log('Memorix Background Control Plane');
433
- console.log('================================');
434
- const statusLabel = health.ok && !pidMismatch
435
- ? '[OK] Running & Healthy'
436
- : pidMismatch
437
- ? '[WARN] PID mismatch — different Memorix instance'
438
- : probablyReused
439
- ? '[WARN] PID reused by unrelated process'
440
- : running
441
- ? '[WARN] Starting up (port not yet bound)'
442
- : '[ERROR] Not running';
443
- console.log(` Status: ${statusLabel}`);
444
- console.log(` PID: ${state.pid}${running ? '' : ' (dead)'}`);
445
- console.log(` Port: ${state.port}`);
446
- console.log(` Started: ${state.startedAt}`);
447
- if (state.instanceToken) console.log(` Instance: ${state.instanceToken.slice(0, 8)}…`);
448
- console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
449
- console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
450
- console.log(` Logs: ${normalizePath(state.logFile)}`);
451
-
452
- if (health.ok && health.data) {
453
- const d = health.data;
454
- console.log('');
455
- console.log(' Health:');
456
- console.log(` PID: ${d.pid ?? 'unknown'}`);
457
- console.log(` Uptime: ${d.uptime ?? 0}s`);
458
- console.log(` Mode: ${d.mode ?? 'unknown'}`);
459
- }
460
-
461
- if (pidMismatch) {
462
- console.log('');
463
- console.log(` [WARN] background.json has PID ${state.pid}, but /health reports PID ${health.data?.pid}.`);
464
- console.log(' The Memorix process was likely restarted. Updating state...');
465
- clearState();
466
- console.log(' Run "memorix background start" to register the current instance.');
467
- } else if (probablyReused) {
468
- console.log('');
469
- console.log(' [WARN] The PID in background.json belongs to a different process.');
470
- console.log(' Cleaning up stale state...');
471
- clearState();
472
- console.log(' Run "memorix background start" to restart.');
473
- } else if (!running) {
474
- console.log('');
475
- console.log(' Process has exited. Cleaning up stale state...');
476
- // Diagnose: check heartbeat for crash evidence
477
- try {
478
- const heartbeatPath = getMemorixDir() + '/background.heartbeat';
479
- const hbData = fs.readFileSync(heartbeatPath, 'utf-8');
480
- const hb = JSON.parse(hbData);
481
- const age = Date.now() - hb.heartbeatAt;
482
- if (age < 300_000) { // heartbeat less than 5 minutes old
483
- console.log(` [WARN] Recent heartbeat found (${Math.round(age / 1000)}s ago, uptime ${hb.uptime ?? '?'}s)`);
484
- console.log(' The control plane likely crashed. Check the log file for errors.');
485
- }
486
- } catch { /* no heartbeat */ }
487
- clearState();
488
- console.log(' Run "memorix background start" to restart.');
489
- }
490
-
491
- console.log('');
492
- }
493
-
494
- async function doRestart(port: number): Promise<void> {
495
- console.log('Restarting control plane...');
496
- await doStop();
497
- // Brief pause to let port release
498
- await new Promise(r => setTimeout(r, 1000));
499
- // Verify port is actually free before starting
500
- for (let i = 0; i < 10; i++) {
501
- const inUse = await isPortInUse(port);
502
- if (!inUse) break;
503
- await new Promise(r => setTimeout(r, 300));
504
- }
505
- await doStart(port);
506
- }
507
-
508
- async function doEnsure(port: number): Promise<void> {
509
- // Lightweight: just check if healthy, auto-start if not.
510
- // Designed for use as a pre-condition by MCP clients (e.g., Windsurf).
511
- const state = loadState();
512
- if (state && isProcessRunning(state.pid)) {
513
- const health = await healthCheck(state.port, 2000);
514
- if (health.ok) {
515
- // Already running and healthy — silent success
516
- return;
517
- }
518
- // PID alive but unhealthy — kill and restart
519
- killProcess(state.pid);
520
- clearState();
521
- } else if (state) {
522
- // PID dead — clean up
523
- clearState();
524
- }
525
-
526
- // Not running — auto-start
527
- await doStart(port);
528
- }
529
-
530
- function doLogs(follow: boolean, lines: number): void {
531
- const logFile = getLogFilePath();
532
-
533
- if (!fs.existsSync(logFile)) {
534
- console.log('No log file found. Start the background service first:');
535
- console.log(' memorix background start');
536
- return;
537
- }
538
-
539
- if (follow) {
540
- // Tail -f equivalent: read existing + watch for changes
541
- const content = fs.readFileSync(logFile, 'utf-8');
542
- const existingLines = content.split('\n');
543
- const tail = existingLines.slice(-lines);
544
- console.log(tail.join('\n'));
545
- console.log('--- Following log (Ctrl+C to stop) ---');
546
-
547
- let position = fs.statSync(logFile).size;
548
- const watcher = fs.watch(logFile, () => {
549
- try {
550
- const stat = fs.statSync(logFile);
551
- if (stat.size > position) {
552
- const fd = fs.openSync(logFile, 'r');
553
- const buf = Buffer.alloc(stat.size - position);
554
- fs.readSync(fd, buf, 0, buf.length, position);
555
- fs.closeSync(fd);
556
- process.stdout.write(buf.toString('utf-8'));
557
- position = stat.size;
558
- }
559
- } catch { /* file may be rotated */ }
560
- });
561
-
562
- // Keep alive until Ctrl+C — setInterval holds the event loop open
563
- setInterval(() => {}, 60_000);
564
- } else {
565
- // Just show last N lines
566
- const content = fs.readFileSync(logFile, 'utf-8');
567
- const allLines = content.split('\n');
568
- const tail = allLines.slice(-lines);
569
- console.log(tail.join('\n'));
570
- }
571
- }
572
-
573
- // ============================================================
574
- // Command definition
575
- // ============================================================
576
-
577
- export default defineCommand({
578
- meta: {
579
- name: 'background',
580
- description: 'Manage the Memorix Control Plane as a background service',
581
- },
582
- args: {
583
- port: {
584
- type: 'string',
585
- description: 'HTTP port (default: 3211)',
586
- required: false,
587
- },
588
- follow: {
589
- type: 'boolean',
590
- alias: 'f',
591
- description: 'Follow log output (for "logs" subcommand)',
592
- required: false,
593
- },
594
- lines: {
595
- type: 'string',
596
- alias: 'n',
597
- description: 'Number of log lines to show (default: 50)',
598
- required: false,
599
- },
600
- },
601
- run: async ({ args }) => {
602
- try {
603
- const subcommand = (args._ as string[])?.[0] || '';
604
- const port = parseInt(args.port || '3211', 10);
605
-
606
- switch (subcommand) {
607
- case 'start':
608
- await doStart(port);
609
- break;
610
- case 'stop':
611
- await doStop();
612
- break;
613
- case 'status':
614
- await doStatus();
615
- break;
616
- case 'restart':
617
- await doRestart(port);
618
- break;
619
- case 'ensure':
620
- // Health check + auto-start if not running.
621
- // Useful as a pre-condition for MCP clients that need the control plane.
622
- // Returns exit code 0 if control plane is running (or was just started).
623
- await doEnsure(port);
624
- break;
625
- case 'logs':
626
- doLogs(!!args.follow, parseInt(args.lines || '50', 10));
627
- break;
628
- default:
629
- console.log('Memorix Background Control Plane');
630
- console.log('');
631
- console.log('Usage:');
632
- console.log(' memorix background start Start control plane in background');
633
- console.log(' memorix background stop Stop the background control plane');
634
- console.log(' memorix background status Show running state and health');
635
- console.log(' memorix background restart Stop + start');
636
- console.log(' memorix background ensure Ensure control plane is running (auto-start if not)');
637
- console.log(' memorix background logs Show recent log output');
638
- console.log('');
639
- console.log('Options:');
640
- console.log(' --port <port> HTTP port (default: 3211)');
641
- console.log(' --follow, -f Follow log output in real-time (for "logs")');
642
- console.log(' --lines, -n Number of log lines to show (default: 50)');
643
- console.log('');
644
- console.log('Mode distinction:');
645
- console.log(' Quick Mode = stdio / single project / zero friction');
646
- console.log(' Control Plane = HTTP / shared MCP / multi-session / live dashboard');
647
- console.log(' Background = Control Plane running as a local service');
648
- break;
649
- }
650
- } catch (err) {
651
- // Ensure errors are never silently swallowed by citty
652
- process.stderr.write(`[memorix background] Error: ${err instanceof Error ? err.message : err}\n`);
653
- if (err instanceof Error && err.stack) {
654
- process.stderr.write(err.stack + '\n');
655
- }
656
- process.exitCode = 1;
657
- }
658
- },
659
- });
1
+ /**
2
+ * memorix background — Manage the Memorix Control Plane as a background service
3
+ *
4
+ * Subcommands:
5
+ * memorix background start — Launch control plane in background (HTTP 3211)
6
+ * memorix background stop — Stop the background control plane
7
+ * memorix background status — Show running state, PID, port, health
8
+ * memorix background restart — Stop + start
9
+ * memorix background logs — Tail the background service log
10
+ *
11
+ * State is persisted in ~/.memorix/background.json so status survives shell restarts.
12
+ * Logs are written to ~/.memorix/background.log.
13
+ *
14
+ * Mode distinction:
15
+ * Quick Mode = stdio / single project / zero friction
16
+ * Control Plane = HTTP / shared MCP / multi-session / live dashboard
17
+ * Background = Control Plane's productized run mode (no terminal babysitting)
18
+ */
19
+
20
+ import { defineCommand } from 'citty';
21
+ import * as fs from 'node:fs';
22
+ import { spawn, execSync } from 'node:child_process';
23
+ import { randomBytes } from 'node:crypto';
24
+
25
+ // ============================================================
26
+ // Paths & Types
27
+ // ============================================================
28
+
29
+ interface BackgroundState {
30
+ pid: number;
31
+ port: number;
32
+ startedAt: string; // ISO timestamp
33
+ logFile: string;
34
+ /** Unique instance token — prevents PID-reuse misidentification */
35
+ instanceToken: string;
36
+ /** Shell cwd at start time (informational only, NOT used as daemon anchor) */
37
+ startCwd?: string;
38
+ }
39
+
40
+ function getMemorixDir(): string {
41
+ const home = process.env.HOME || process.env.USERPROFILE || '';
42
+ // Use join-style path to ensure native separators on Windows
43
+ return home.replace(/\\/g, '/') + '/.memorix';
44
+ }
45
+
46
+ function normalizePath(p: string): string {
47
+ // Normalize to OS-native separators for display
48
+ return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
49
+ }
50
+
51
+ function getStateFilePath(): string {
52
+ return getMemorixDir() + '/background.json';
53
+ }
54
+
55
+ function getLogFilePath(): string {
56
+ return getMemorixDir() + '/background.log';
57
+ }
58
+
59
+ // ============================================================
60
+ // State persistence
61
+ // ============================================================
62
+
63
+ function loadState(): BackgroundState | null {
64
+ try {
65
+ const data = fs.readFileSync(getStateFilePath(), 'utf-8');
66
+ return JSON.parse(data) as BackgroundState;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function saveState(state: BackgroundState): void {
73
+ const dir = getMemorixDir();
74
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
75
+ fs.writeFileSync(getStateFilePath(), JSON.stringify(state, null, 2));
76
+ }
77
+
78
+ function clearState(): void {
79
+ try {
80
+ fs.unlinkSync(getStateFilePath());
81
+ } catch { /* already gone */ }
82
+ }
83
+
84
+ // ============================================================
85
+ // Process utilities (cross-platform)
86
+ // ============================================================
87
+
88
+ function isProcessRunning(pid: number): boolean {
89
+ try {
90
+ process.kill(pid, 0); // Signal 0 = existence check, no actual signal sent
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ function killProcess(pid: number): boolean {
98
+ try {
99
+ // On Windows, process.kill sends SIGTERM which works for Node child processes
100
+ process.kill(pid);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ async function healthCheck(port: number, timeoutMs = 3000): Promise<{ ok: boolean; data?: any; error?: string }> {
108
+ try {
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
111
+ // Use /health endpoint — lightweight, no TeamStore or heavy init required.
112
+ // Responds immediately once the HTTP server has bound the port.
113
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
114
+ signal: controller.signal,
115
+ });
116
+ clearTimeout(timer);
117
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
118
+ const data = await res.json();
119
+ return { ok: true, data };
120
+ } catch (err) {
121
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
122
+ }
123
+ }
124
+
125
+ async function isPortInUse(port: number): Promise<boolean> {
126
+ const health = await healthCheck(port, 1500);
127
+ return health.ok;
128
+ }
129
+
130
+ // ============================================================
131
+ // Subcommands
132
+ // ============================================================
133
+
134
+ export async function doStart(port: number): Promise<void> {
135
+ // 1. Check if already running — validate both PID existence AND HTTP health
136
+ const state = loadState();
137
+ if (state) {
138
+ if (isProcessRunning(state.pid)) {
139
+ const health = await healthCheck(state.port, 2000);
140
+ if (health.ok) {
141
+ console.log(`[OK] Control plane is already running (PID ${state.pid}, port ${state.port})`);
142
+ console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
143
+ console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
144
+ return;
145
+ }
146
+ // PID alive but HTTP not responding — either still starting or stale
147
+ // Give it one more chance with a longer timeout (5s)
148
+ const retry = await healthCheck(state.port, 5000);
149
+ if (retry.ok) {
150
+ console.log(`[OK] Control plane is already running (PID ${state.pid}, port ${state.port})`);
151
+ console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
152
+ console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
153
+ return;
154
+ }
155
+ // Stale or PID-reused — kill and auto-restart
156
+ console.log(`[WARN] Stale process ${state.pid} detected (PID alive but port ${state.port} not responding), cleaning up...`);
157
+ killProcess(state.pid);
158
+ clearState();
159
+ // Fall through to auto-restart below
160
+ } else {
161
+ // PID dead — clean up stale state and auto-restart
162
+ console.log(`[WARN] Previous process (PID ${state.pid}) is dead. Cleaning up stale state...`);
163
+ clearState();
164
+ // Fall through to auto-restart below
165
+ }
166
+ } else {
167
+ // No background.json — check heartbeat for crash evidence
168
+ try {
169
+ const heartbeatPath = getMemorixDir() + '/background.heartbeat';
170
+ const hbData = fs.readFileSync(heartbeatPath, 'utf-8');
171
+ const hb = JSON.parse(hbData);
172
+ const age = Date.now() - hb.heartbeatAt;
173
+ if (age < 120_000) { // heartbeat less than 2 minutes old
174
+ console.log(`[WARN] Recent heartbeat found (PID ${hb.pid}, ${Math.round(age / 1000)}s ago) but no background.json.`);
175
+ console.log(' The control plane likely crashed. Auto-restarting...');
176
+ }
177
+ try { fs.unlinkSync(heartbeatPath); } catch { /* ok */ }
178
+ } catch { /* no heartbeat — first run or clean shutdown */ }
179
+ }
180
+
181
+ // 2. Check if port is already taken by another process
182
+ if (await isPortInUse(port)) {
183
+ // Port is occupied but no background.json — this is an unmanaged foreground process
184
+ console.log('');
185
+ console.log(`[WARN] Port ${port} is already serving a Memorix control plane, but it is NOT managed by background mode.`);
186
+ console.log('');
187
+ console.log(' This is likely a foreground "memorix serve-http" instance.');
188
+ console.log(' To switch to background mode:');
189
+ console.log(' 1. Stop the foreground instance (Ctrl+C in its terminal)');
190
+ console.log(' 2. Run "memorix background start"');
191
+ console.log('');
192
+ console.log(` Dashboard: http://127.0.0.1:${port}/`);
193
+ console.log(` MCP: http://127.0.0.1:${port}/mcp`);
194
+ return;
195
+ }
196
+
197
+ // 3. Clean up stale readiness file from previous run
198
+ try {
199
+ const readyFile = getMemorixDir() + '/background.ready';
200
+ if (fs.existsSync(readyFile)) fs.unlinkSync(readyFile);
201
+ } catch { /* ignore */ }
202
+
203
+ // 4. Prepare log file
204
+ const logFile = getLogFilePath();
205
+ const dir = getMemorixDir();
206
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
207
+
208
+ // Rotate log: keep last run's log as .log.prev
209
+ if (fs.existsSync(logFile)) {
210
+ try { fs.renameSync(logFile, logFile + '.prev'); } catch { /* ok */ }
211
+ }
212
+
213
+ const logFd = fs.openSync(logFile, 'a');
214
+
215
+ // 5. Find the memorix CLI entry point
216
+ // We need to spawn `node <cli-entry> serve-http --port <port>`
217
+ // The entry point is the same binary that's running now
218
+ const cliEntry = process.argv[1]; // e.g., dist/cli/index.js or node_modules/.bin/memorix
219
+
220
+ // 6. Spawn detached process
221
+ // On Windows, detached:true + stdio:['ignore', fd, fd] + unref() is the correct
222
+ // pattern. The child gets its own console and survives the parent terminal closing.
223
+ // Note: Node.js spawn does NOT support a 'flags' option — that was a dead code path.
224
+ const child = spawn(process.execPath, [cliEntry, 'serve-http', '--port', String(port)], {
225
+ detached: true,
226
+ stdio: ['ignore', logFd, logFd],
227
+ env: { ...process.env },
228
+ cwd: process.cwd(),
229
+ windowsHide: true,
230
+ });
231
+
232
+ child.unref();
233
+ fs.closeSync(logFd);
234
+
235
+ const pid = child.pid;
236
+ if (!pid) {
237
+ console.error('[ERROR] Failed to spawn background process');
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+
242
+ // 7. Generate instance token and save state (synchronous — no yield point before output)
243
+ const instanceToken = randomBytes(8).toString('hex');
244
+ saveState({
245
+ pid,
246
+ port,
247
+ startedAt: new Date().toISOString(),
248
+ logFile,
249
+ instanceToken,
250
+ startCwd: process.cwd(),
251
+ });
252
+
253
+ // 8. Print essential info immediately
254
+ // Use stderr for the critical startup line — stderr is ALWAYS unbuffered/synchronous,
255
+ // even when stdout is a pipe or in a non-TTY environment.
256
+ const startMsg = [
257
+ '',
258
+ 'Starting Memorix Control Plane in background...',
259
+ '',
260
+ ` PID: ${pid}`,
261
+ ` Port: ${port}`,
262
+ ` Dashboard: http://127.0.0.1:${port}/`,
263
+ ` MCP: http://127.0.0.1:${port}/mcp`,
264
+ ` Logs: ${normalizePath(logFile)}`,
265
+ '',
266
+ ].join('\n');
267
+ process.stderr.write(startMsg + '\n');
268
+
269
+ // 9. Wait for readiness — ALWAYS, even in non-interactive mode.
270
+ // The /health endpoint responds immediately once the HTTP server has bound the port,
271
+ // so this typically completes in 1-3 seconds. Non-interactive callers (AI agents)
272
+ // need the server to be actually ready before they try to connect.
273
+ // Timeout: 30 seconds (large projects may need time to reindex before listen()).
274
+ let healthy = false;
275
+ for (let i = 0; i < 60; i++) {
276
+ await new Promise(r => setTimeout(r, 500));
277
+ const check = await healthCheck(port, 2000);
278
+ if (check.ok) {
279
+ healthy = true;
280
+ break;
281
+ }
282
+ // Check if process died
283
+ if (!isProcessRunning(pid)) {
284
+ console.error('[ERROR] Background process exited unexpectedly.');
285
+ console.error(` Check logs: ${normalizePath(logFile)}`);
286
+ clearState();
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ }
291
+
292
+ if (healthy) {
293
+ process.stderr.write('[OK] Control plane is running and healthy.\n');
294
+ } else {
295
+ process.stderr.write('[WARN] Health check timed out — service may still be initializing.\n');
296
+ process.stderr.write(' Check later: memorix background status\n');
297
+ }
298
+
299
+ const footer = [
300
+ '',
301
+ ' Quick Mode = stdio / single project / zero friction',
302
+ ' Control Plane = HTTP / shared MCP / multi-session / live dashboard',
303
+ ' Background = Control Plane running as a local service',
304
+ '',
305
+ ' Stop with: memorix background stop',
306
+ ].join('\n');
307
+ process.stderr.write(footer + '\n');
308
+ }
309
+
310
+ export async function doStop(): Promise<void> {
311
+ const state = loadState();
312
+
313
+ if (!state) {
314
+ console.log('No background control plane is registered.');
315
+ return;
316
+ }
317
+
318
+ if (!isProcessRunning(state.pid)) {
319
+ console.log(`Background process (PID ${state.pid}) is not running. Cleaning up state.`);
320
+ clearState();
321
+ // Also clean up readiness file
322
+ try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
323
+ return;
324
+ }
325
+
326
+ // Validate that the PID is actually our Memorix process — check /health
327
+ const health = await healthCheck(state.port, 2000);
328
+ if (!health.ok) {
329
+ // PID alive but port not responding — likely PID-reused or process stuck
330
+ console.log(`[WARN] PID ${state.pid} is alive but port ${state.port} is not responding.`);
331
+ console.log(' This may be a PID-reused unrelated process. Force-killing and cleaning up stale state.');
332
+ try {
333
+ if (process.platform === 'win32') {
334
+ execSync(`taskkill /F /PID ${state.pid}`, { stdio: 'ignore' });
335
+ } else {
336
+ process.kill(state.pid, 'SIGKILL');
337
+ }
338
+ } catch { /* best effort */ }
339
+ clearState();
340
+ try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
341
+ return;
342
+ }
343
+
344
+ // Verify PID matches — if /health returns a different PID, it's a different process
345
+ if (health.data?.pid && health.data.pid !== state.pid) {
346
+ console.log(`[WARN] PID mismatch: background.json has ${state.pid}, but /health reports ${health.data.pid}.`);
347
+ console.log(' The Memorix process was likely restarted. Updating state...');
348
+ // Don't kill — this is a valid Memorix, just with a different PID than we expected
349
+ // (e.g., process was restarted manually)
350
+ clearState();
351
+ console.log(' Run "memorix background start" to register the current instance.');
352
+ return;
353
+ }
354
+
355
+ console.log(`Stopping control plane (PID ${state.pid}, port ${state.port})...`);
356
+
357
+ // Try graceful HTTP shutdown first
358
+ let graceful = false;
359
+ try {
360
+ // Send SIGTERM — the serve-http handler has a graceful shutdown handler
361
+ killProcess(state.pid);
362
+ // Wait up to 5 seconds for process to exit
363
+ for (let i = 0; i < 10; i++) {
364
+ await new Promise(r => setTimeout(r, 500));
365
+ if (!isProcessRunning(state.pid)) {
366
+ graceful = true;
367
+ break;
368
+ }
369
+ }
370
+ } catch { /* process may already be gone */ }
371
+
372
+ if (!graceful && isProcessRunning(state.pid)) {
373
+ // Force kill on Windows
374
+ try {
375
+ if (process.platform === 'win32') {
376
+ execSync(`taskkill /F /PID ${state.pid}`, { stdio: 'ignore' });
377
+ } else {
378
+ process.kill(state.pid, 'SIGKILL');
379
+ }
380
+ } catch { /* best effort */ }
381
+ }
382
+
383
+ clearState();
384
+ // Clean up readiness file
385
+ try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* ignore */ }
386
+ console.log('[OK] Control plane stopped.');
387
+ }
388
+
389
+ async function doStatus(): Promise<void> {
390
+ const state = loadState();
391
+
392
+ if (!state) {
393
+ // No background.json — but check if port has an unmanaged foreground instance
394
+ const portHealth = await healthCheck(3211, 2000);
395
+ if (portHealth.ok) {
396
+ console.log('');
397
+ console.log('No background control plane is registered,');
398
+ console.log('but a Memorix instance IS running on port 3211 (likely a foreground "memorix serve-http").');
399
+ console.log('');
400
+ console.log(` Dashboard: http://127.0.0.1:3211/`);
401
+ console.log(` MCP: http://127.0.0.1:3211/mcp`);
402
+ if (portHealth.data) {
403
+ const d = portHealth.data;
404
+ console.log('');
405
+ console.log(' Health:');
406
+ console.log(` PID: ${d.pid ?? 'unknown'}`);
407
+ console.log(` Uptime: ${d.uptime ?? 0}s`);
408
+ }
409
+ console.log('');
410
+ console.log(' To switch to background mode:');
411
+ console.log(' 1. Stop the foreground instance (Ctrl+C in its terminal)');
412
+ console.log(' 2. Run "memorix background start"');
413
+ console.log('');
414
+ } else {
415
+ console.log('No background control plane is registered.');
416
+ console.log('');
417
+ console.log('Start one with: memorix background start');
418
+ }
419
+ return;
420
+ }
421
+
422
+ const running = isProcessRunning(state.pid);
423
+
424
+ // Always do HTTP health check — this is the ground truth, not just PID existence
425
+ const health = running ? await healthCheck(state.port) : { ok: false, error: 'Process not running' };
426
+
427
+ // PID reuse detection: PID alive but /health fails or returns different PID
428
+ const pidMismatch = health.ok && health.data?.pid && health.data.pid !== state.pid;
429
+ const probablyReused = running && !health.ok;
430
+
431
+ console.log('');
432
+ console.log('Memorix Background Control Plane');
433
+ console.log('================================');
434
+ const statusLabel = health.ok && !pidMismatch
435
+ ? '[OK] Running & Healthy'
436
+ : pidMismatch
437
+ ? '[WARN] PID mismatch — different Memorix instance'
438
+ : probablyReused
439
+ ? '[WARN] PID reused by unrelated process'
440
+ : running
441
+ ? '[WARN] Starting up (port not yet bound)'
442
+ : '[ERROR] Not running';
443
+ console.log(` Status: ${statusLabel}`);
444
+ console.log(` PID: ${state.pid}${running ? '' : ' (dead)'}`);
445
+ console.log(` Port: ${state.port}`);
446
+ console.log(` Started: ${state.startedAt}`);
447
+ if (state.instanceToken) console.log(` Instance: ${state.instanceToken.slice(0, 8)}…`);
448
+ console.log(` Dashboard: http://127.0.0.1:${state.port}/`);
449
+ console.log(` MCP: http://127.0.0.1:${state.port}/mcp`);
450
+ console.log(` Logs: ${normalizePath(state.logFile)}`);
451
+
452
+ if (health.ok && health.data) {
453
+ const d = health.data;
454
+ console.log('');
455
+ console.log(' Health:');
456
+ console.log(` PID: ${d.pid ?? 'unknown'}`);
457
+ console.log(` Uptime: ${d.uptime ?? 0}s`);
458
+ console.log(` Mode: ${d.mode ?? 'unknown'}`);
459
+ }
460
+
461
+ if (pidMismatch) {
462
+ console.log('');
463
+ console.log(` [WARN] background.json has PID ${state.pid}, but /health reports PID ${health.data?.pid}.`);
464
+ console.log(' The Memorix process was likely restarted. Updating state...');
465
+ clearState();
466
+ console.log(' Run "memorix background start" to register the current instance.');
467
+ } else if (probablyReused) {
468
+ console.log('');
469
+ console.log(' [WARN] The PID in background.json belongs to a different process.');
470
+ console.log(' Cleaning up stale state...');
471
+ clearState();
472
+ console.log(' Run "memorix background start" to restart.');
473
+ } else if (!running) {
474
+ console.log('');
475
+ console.log(' Process has exited. Cleaning up stale state...');
476
+ // Diagnose: check heartbeat for crash evidence
477
+ try {
478
+ const heartbeatPath = getMemorixDir() + '/background.heartbeat';
479
+ const hbData = fs.readFileSync(heartbeatPath, 'utf-8');
480
+ const hb = JSON.parse(hbData);
481
+ const age = Date.now() - hb.heartbeatAt;
482
+ if (age < 300_000) { // heartbeat less than 5 minutes old
483
+ console.log(` [WARN] Recent heartbeat found (${Math.round(age / 1000)}s ago, uptime ${hb.uptime ?? '?'}s)`);
484
+ console.log(' The control plane likely crashed. Check the log file for errors.');
485
+ }
486
+ } catch { /* no heartbeat */ }
487
+ clearState();
488
+ console.log(' Run "memorix background start" to restart.');
489
+ }
490
+
491
+ console.log('');
492
+ }
493
+
494
+ async function doRestart(port: number): Promise<void> {
495
+ console.log('Restarting control plane...');
496
+ await doStop();
497
+ // Brief pause to let port release
498
+ await new Promise(r => setTimeout(r, 1000));
499
+ // Verify port is actually free before starting
500
+ for (let i = 0; i < 10; i++) {
501
+ const inUse = await isPortInUse(port);
502
+ if (!inUse) break;
503
+ await new Promise(r => setTimeout(r, 300));
504
+ }
505
+ await doStart(port);
506
+ }
507
+
508
+ async function doEnsure(port: number): Promise<void> {
509
+ // Lightweight: just check if healthy, auto-start if not.
510
+ // Designed for use as a pre-condition by MCP clients (e.g., Windsurf).
511
+ const state = loadState();
512
+ if (state && isProcessRunning(state.pid)) {
513
+ const health = await healthCheck(state.port, 2000);
514
+ if (health.ok) {
515
+ // Already running and healthy — silent success
516
+ return;
517
+ }
518
+ // PID alive but unhealthy — kill and restart
519
+ killProcess(state.pid);
520
+ clearState();
521
+ } else if (state) {
522
+ // PID dead — clean up
523
+ clearState();
524
+ }
525
+
526
+ // Not running — auto-start
527
+ await doStart(port);
528
+ }
529
+
530
+ function doLogs(follow: boolean, lines: number): void {
531
+ const logFile = getLogFilePath();
532
+
533
+ if (!fs.existsSync(logFile)) {
534
+ console.log('No log file found. Start the background service first:');
535
+ console.log(' memorix background start');
536
+ return;
537
+ }
538
+
539
+ if (follow) {
540
+ // Tail -f equivalent: read existing + watch for changes
541
+ const content = fs.readFileSync(logFile, 'utf-8');
542
+ const existingLines = content.split('\n');
543
+ const tail = existingLines.slice(-lines);
544
+ console.log(tail.join('\n'));
545
+ console.log('--- Following log (Ctrl+C to stop) ---');
546
+
547
+ let position = fs.statSync(logFile).size;
548
+ const watcher = fs.watch(logFile, () => {
549
+ try {
550
+ const stat = fs.statSync(logFile);
551
+ if (stat.size > position) {
552
+ const fd = fs.openSync(logFile, 'r');
553
+ const buf = Buffer.alloc(stat.size - position);
554
+ fs.readSync(fd, buf, 0, buf.length, position);
555
+ fs.closeSync(fd);
556
+ process.stdout.write(buf.toString('utf-8'));
557
+ position = stat.size;
558
+ }
559
+ } catch { /* file may be rotated */ }
560
+ });
561
+
562
+ // Keep alive until Ctrl+C — setInterval holds the event loop open
563
+ setInterval(() => {}, 60_000);
564
+ } else {
565
+ // Just show last N lines
566
+ const content = fs.readFileSync(logFile, 'utf-8');
567
+ const allLines = content.split('\n');
568
+ const tail = allLines.slice(-lines);
569
+ console.log(tail.join('\n'));
570
+ }
571
+ }
572
+
573
+ // ============================================================
574
+ // Command definition
575
+ // ============================================================
576
+
577
+ export default defineCommand({
578
+ meta: {
579
+ name: 'background',
580
+ description: 'Manage the Memorix Control Plane as a background service',
581
+ },
582
+ args: {
583
+ port: {
584
+ type: 'string',
585
+ description: 'HTTP port (default: 3211)',
586
+ required: false,
587
+ },
588
+ follow: {
589
+ type: 'boolean',
590
+ alias: 'f',
591
+ description: 'Follow log output (for "logs" subcommand)',
592
+ required: false,
593
+ },
594
+ lines: {
595
+ type: 'string',
596
+ alias: 'n',
597
+ description: 'Number of log lines to show (default: 50)',
598
+ required: false,
599
+ },
600
+ },
601
+ run: async ({ args }) => {
602
+ try {
603
+ const subcommand = (args._ as string[])?.[0] || '';
604
+ const port = parseInt(args.port || '3211', 10);
605
+
606
+ switch (subcommand) {
607
+ case 'start':
608
+ await doStart(port);
609
+ break;
610
+ case 'stop':
611
+ await doStop();
612
+ break;
613
+ case 'status':
614
+ await doStatus();
615
+ break;
616
+ case 'restart':
617
+ await doRestart(port);
618
+ break;
619
+ case 'ensure':
620
+ // Health check + auto-start if not running.
621
+ // Useful as a pre-condition for MCP clients that need the control plane.
622
+ // Returns exit code 0 if control plane is running (or was just started).
623
+ await doEnsure(port);
624
+ break;
625
+ case 'logs':
626
+ doLogs(!!args.follow, parseInt(args.lines || '50', 10));
627
+ break;
628
+ default:
629
+ console.log('Memorix Background Control Plane');
630
+ console.log('');
631
+ console.log('Usage:');
632
+ console.log(' memorix background start Start control plane in background');
633
+ console.log(' memorix background stop Stop the background control plane');
634
+ console.log(' memorix background status Show running state and health');
635
+ console.log(' memorix background restart Stop + start');
636
+ console.log(' memorix background ensure Ensure control plane is running (auto-start if not)');
637
+ console.log(' memorix background logs Show recent log output');
638
+ console.log('');
639
+ console.log('Options:');
640
+ console.log(' --port <port> HTTP port (default: 3211)');
641
+ console.log(' --follow, -f Follow log output in real-time (for "logs")');
642
+ console.log(' --lines, -n Number of log lines to show (default: 50)');
643
+ console.log('');
644
+ console.log('Mode distinction:');
645
+ console.log(' Quick Mode = stdio / single project / zero friction');
646
+ console.log(' Control Plane = HTTP / shared MCP / multi-session / live dashboard');
647
+ console.log(' Background = Control Plane running as a local service');
648
+ break;
649
+ }
650
+ } catch (err) {
651
+ // Ensure errors are never silently swallowed by citty
652
+ process.stderr.write(`[memorix background] Error: ${err instanceof Error ? err.message : err}\n`);
653
+ if (err instanceof Error && err.stack) {
654
+ process.stderr.write(err.stack + '\n');
655
+ }
656
+ process.exitCode = 1;
657
+ }
658
+ },
659
+ });