memorix 1.2.0 → 1.2.2

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 (212) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +18 -4
  3. package/README.zh-CN.md +18 -4
  4. package/TEAM.md +86 -86
  5. package/dist/cli/index.js +15919 -14055
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.js +1997 -1021
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +8481 -8005
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +30 -1
  13. package/dist/sdk.d.ts +7 -2
  14. package/dist/sdk.js +2022 -1024
  15. package/dist/sdk.js.map +1 -1
  16. package/dist/types.d.ts +49 -1
  17. package/dist/types.js.map +1 -1
  18. package/docs/1.2.2-MEMORY-CONTROL-PLANE.md +434 -0
  19. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -0
  20. package/docs/API_REFERENCE.md +27 -5
  21. package/docs/DESIGN_DECISIONS.md +357 -357
  22. package/docs/DEVELOPMENT.md +4 -0
  23. package/docs/README.md +1 -1
  24. package/docs/SETUP.md +7 -1
  25. package/docs/dev-log/progress.txt +91 -11
  26. package/docs/knowledge/workflows/memorix-release.md +57 -0
  27. package/package.json +1 -1
  28. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  29. package/src/audit/index.ts +156 -156
  30. package/src/cli/command-guide.ts +192 -0
  31. package/src/cli/commands/audit-list.ts +89 -89
  32. package/src/cli/commands/audit.ts +9 -4
  33. package/src/cli/commands/background.ts +659 -659
  34. package/src/cli/commands/cleanup.ts +5 -1
  35. package/src/cli/commands/codegraph.ts +17 -8
  36. package/src/cli/commands/context.ts +3 -2
  37. package/src/cli/commands/doctor.ts +4 -2
  38. package/src/cli/commands/explain.ts +9 -3
  39. package/src/cli/commands/formation.ts +48 -48
  40. package/src/cli/commands/git-hook-install.ts +111 -111
  41. package/src/cli/commands/handoff.ts +75 -61
  42. package/src/cli/commands/hooks-status.ts +63 -63
  43. package/src/cli/commands/identity.ts +116 -0
  44. package/src/cli/commands/ingest-commit.ts +153 -153
  45. package/src/cli/commands/ingest-image.ts +71 -69
  46. package/src/cli/commands/ingest-log.ts +180 -180
  47. package/src/cli/commands/ingest.ts +44 -44
  48. package/src/cli/commands/integrate-shared.ts +15 -15
  49. package/src/cli/commands/knowledge.ts +40 -0
  50. package/src/cli/commands/lock.ts +93 -92
  51. package/src/cli/commands/memory.ts +58 -21
  52. package/src/cli/commands/message.ts +123 -118
  53. package/src/cli/commands/operator-shared.ts +98 -3
  54. package/src/cli/commands/poll.ts +74 -64
  55. package/src/cli/commands/purge-all-memory.ts +85 -85
  56. package/src/cli/commands/purge-project-memory.ts +83 -83
  57. package/src/cli/commands/reasoning.ts +135 -121
  58. package/src/cli/commands/retention.ts +9 -4
  59. package/src/cli/commands/serve-http.ts +22 -43
  60. package/src/cli/commands/serve-shared.ts +118 -118
  61. package/src/cli/commands/session.ts +29 -3
  62. package/src/cli/commands/setup.ts +9 -3
  63. package/src/cli/commands/skills.ts +124 -119
  64. package/src/cli/commands/status.ts +4 -3
  65. package/src/cli/commands/task.ts +193 -184
  66. package/src/cli/commands/team.ts +14 -10
  67. package/src/cli/commands/transfer.ts +108 -55
  68. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  69. package/src/cli/identity.ts +89 -0
  70. package/src/cli/index.ts +96 -19
  71. package/src/cli/invocation.ts +115 -0
  72. package/src/cli/tui/ChatView.tsx +234 -234
  73. package/src/cli/tui/CommandBar.tsx +312 -312
  74. package/src/cli/tui/ContextRail.tsx +118 -118
  75. package/src/cli/tui/HeaderBar.tsx +72 -72
  76. package/src/cli/tui/LogoBanner.tsx +51 -51
  77. package/src/cli/tui/Sidebar.tsx +179 -179
  78. package/src/cli/tui/chat-service.ts +41 -18
  79. package/src/cli/tui/data.ts +23 -44
  80. package/src/cli/tui/index.ts +41 -41
  81. package/src/cli/tui/markdown-render.tsx +371 -371
  82. package/src/cli/tui/operator-context.ts +60 -0
  83. package/src/cli/tui/use-mouse.ts +157 -157
  84. package/src/cli/tui/useNavigation.ts +56 -56
  85. package/src/cli/tui/views/MemoryView.tsx +10 -8
  86. package/src/cli/update-checker.ts +211 -211
  87. package/src/cli/version.ts +7 -7
  88. package/src/cli/workbench.ts +1 -1
  89. package/src/codegraph/auto-context.ts +34 -17
  90. package/src/codegraph/context-pack.ts +1 -0
  91. package/src/codegraph/current-facts.ts +19 -1
  92. package/src/codegraph/project-context.ts +2 -0
  93. package/src/codegraph/task-lens.ts +49 -5
  94. package/src/compact/engine.ts +26 -10
  95. package/src/compact/index-format.ts +25 -2
  96. package/src/compact/token-budget.ts +74 -74
  97. package/src/dashboard/project-classification.ts +64 -64
  98. package/src/dashboard/server.ts +58 -52
  99. package/src/embedding/fastembed-provider.ts +142 -142
  100. package/src/embedding/transformers-provider.ts +111 -111
  101. package/src/git/extractor.ts +209 -209
  102. package/src/git/hooks-path.ts +85 -85
  103. package/src/hooks/admission.ts +117 -0
  104. package/src/hooks/handler.ts +98 -91
  105. package/src/hooks/pattern-detector.ts +173 -173
  106. package/src/hooks/significance-filter.ts +250 -250
  107. package/src/knowledge/claims.ts +51 -1
  108. package/src/knowledge/context-assembly.ts +97 -0
  109. package/src/knowledge/types.ts +1 -0
  110. package/src/knowledge/workflows.ts +34 -3
  111. package/src/knowledge/workset.ts +179 -10
  112. package/src/llm/memory-manager.ts +328 -328
  113. package/src/llm/provider.ts +885 -885
  114. package/src/llm/quality.ts +248 -248
  115. package/src/memory/admission.ts +57 -0
  116. package/src/memory/attribution-guard.ts +249 -249
  117. package/src/memory/auto-relations.ts +21 -0
  118. package/src/memory/consolidation.ts +13 -2
  119. package/src/memory/disclosure-policy.ts +140 -135
  120. package/src/memory/entity-extractor.ts +197 -197
  121. package/src/memory/export-import.ts +11 -3
  122. package/src/memory/formation/evaluate.ts +217 -217
  123. package/src/memory/formation/extract.ts +361 -361
  124. package/src/memory/formation/index.ts +417 -417
  125. package/src/memory/formation/resolve.ts +344 -344
  126. package/src/memory/formation/types.ts +315 -315
  127. package/src/memory/freshness.ts +122 -122
  128. package/src/memory/graph-context.ts +8 -2
  129. package/src/memory/graph-scope.ts +46 -0
  130. package/src/memory/graph.ts +197 -197
  131. package/src/memory/observations.ts +162 -4
  132. package/src/memory/quality-audit.ts +2 -0
  133. package/src/memory/refs.ts +94 -94
  134. package/src/memory/retention.ts +22 -2
  135. package/src/memory/secret-filter.ts +79 -79
  136. package/src/memory/session.ts +5 -2
  137. package/src/memory/visibility.ts +80 -0
  138. package/src/multimodal/image-loader.ts +143 -143
  139. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  140. package/src/orchestrate/adapters/claude.ts +111 -111
  141. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  142. package/src/orchestrate/adapters/codex.ts +41 -41
  143. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  144. package/src/orchestrate/adapters/gemini.ts +42 -42
  145. package/src/orchestrate/adapters/index.ts +73 -73
  146. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  147. package/src/orchestrate/adapters/opencode.ts +47 -47
  148. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  149. package/src/orchestrate/adapters/types.ts +77 -77
  150. package/src/orchestrate/capability-router.ts +284 -284
  151. package/src/orchestrate/context-compact.ts +188 -188
  152. package/src/orchestrate/cost-tracker.ts +219 -219
  153. package/src/orchestrate/error-recovery.ts +191 -191
  154. package/src/orchestrate/evidence.ts +140 -140
  155. package/src/orchestrate/ledger.ts +110 -110
  156. package/src/orchestrate/memorix-bridge.ts +378 -340
  157. package/src/orchestrate/output-budget.ts +80 -80
  158. package/src/orchestrate/permission.ts +152 -152
  159. package/src/orchestrate/pipeline-trace.ts +131 -131
  160. package/src/orchestrate/prompt-builder.ts +155 -155
  161. package/src/orchestrate/ring-buffer.ts +37 -37
  162. package/src/orchestrate/task-graph.ts +389 -389
  163. package/src/orchestrate/verify-gate.ts +33 -10
  164. package/src/orchestrate/worktree.ts +232 -232
  165. package/src/project/aliases.ts +374 -374
  166. package/src/project/detector.ts +268 -268
  167. package/src/rules/adapters/claude-code.ts +99 -99
  168. package/src/rules/adapters/codex.ts +97 -97
  169. package/src/rules/adapters/copilot.ts +124 -124
  170. package/src/rules/adapters/cursor.ts +114 -114
  171. package/src/rules/adapters/kiro.ts +126 -126
  172. package/src/rules/adapters/trae.ts +56 -56
  173. package/src/rules/adapters/windsurf.ts +83 -83
  174. package/src/rules/syncer.ts +235 -235
  175. package/src/runtime/control-plane-maintenance.ts +1 -0
  176. package/src/runtime/isolated-maintenance.ts +1 -0
  177. package/src/runtime/lifecycle.ts +18 -0
  178. package/src/runtime/maintenance-jobs.ts +1 -0
  179. package/src/runtime/maintenance-runner.ts +2 -0
  180. package/src/runtime/project-maintenance.ts +89 -0
  181. package/src/sdk.ts +334 -304
  182. package/src/search/intent-detector.ts +289 -289
  183. package/src/search/query-expansion.ts +52 -52
  184. package/src/server/formation-timeout.ts +27 -27
  185. package/src/server.ts +334 -93
  186. package/src/skills/mini-skills.ts +386 -386
  187. package/src/store/chat-store.ts +119 -119
  188. package/src/store/graph-store.ts +249 -249
  189. package/src/store/mini-skill-store.ts +349 -349
  190. package/src/store/orama-store.ts +61 -6
  191. package/src/store/persistence-json.ts +212 -212
  192. package/src/store/persistence.ts +291 -291
  193. package/src/store/project-affinity.ts +195 -195
  194. package/src/store/sqlite-db.ts +23 -1
  195. package/src/store/sqlite-store.ts +12 -2
  196. package/src/team/event-bus.ts +76 -76
  197. package/src/team/file-locks.ts +173 -173
  198. package/src/team/handoff.ts +168 -161
  199. package/src/team/messages.ts +203 -203
  200. package/src/team/poll.ts +132 -132
  201. package/src/team/tasks.ts +211 -211
  202. package/src/types.ts +51 -0
  203. package/src/wiki/generator.ts +2 -0
  204. package/src/workspace/mcp-adapters/codex.ts +191 -191
  205. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  206. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  207. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  208. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  209. package/src/workspace/mcp-adapters/trae.ts +134 -134
  210. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  211. package/src/workspace/sanitizer.ts +60 -60
  212. 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
+ });