memorix 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/TEAM.md +86 -86
- package/dist/cli/index.js +5199 -4726
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +428 -49
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +97 -18
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +27 -0
- package/dist/sdk.js +428 -49
- package/dist/sdk.js.map +1 -1
- package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +86 -0
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +13 -1
- package/docs/API_REFERENCE.md +13 -3
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/dev-log/progress.txt +60 -9
- package/package.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/capability-map.ts +1 -1
- package/src/cli/command-guide.ts +4 -1
- package/src/cli/commands/agent-integrations.ts +5 -1
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/codegraph.ts +1 -1
- package/src/cli/commands/context.ts +9 -1
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +54 -54
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +66 -66
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +82 -82
- package/src/cli/commands/message.ts +104 -104
- package/src/cli/commands/poll.ts +58 -58
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +118 -118
- package/src/cli/commands/resume.ts +31 -0
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/session.ts +15 -7
- package/src/cli/commands/skills.ts +114 -114
- package/src/cli/commands/task.ts +167 -167
- package/src/cli/commands/transfer.ts +47 -47
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/session-service.ts +3 -2
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/auto-context.ts +54 -1
- package/src/codegraph/task-lens.ts +29 -0
- package/src/compact/token-budget.ts +89 -74
- package/src/config/toml-loader.ts +9 -5
- package/src/dashboard/project-classification.ts +64 -64
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/hooks/handler.ts +127 -66
- package/src/hooks/installers/index.ts +5 -4
- package/src/hooks/official-skills.ts +6 -4
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +9 -7
- package/src/hooks/significance-filter.ts +250 -250
- package/src/knowledge/context-assembly.ts +4 -1
- package/src/knowledge/workset.ts +89 -1
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/disclosure-policy.ts +135 -135
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +158 -9
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +343 -343
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +299 -299
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/server.ts +144 -10
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/bun-sqlite-compat.ts +118 -15
- package/src/store/chat-store.ts +119 -119
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/sqlite-db.ts +3 -3
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +161 -161
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- 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
|
+
});
|