@dmsdc-ai/aigentry-deliberation 0.0.1

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/install.js ADDED
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Deliberation MCP Server β€” One-click installer
5
+ *
6
+ * Usage:
7
+ * npx @dmsdc-ai/aigentry-deliberation install
8
+ * node install.js
9
+ *
10
+ * What it does:
11
+ * 1. Copies server files to ~/.local/lib/mcp-deliberation/
12
+ * 2. Installs npm dependencies
13
+ * 3. Registers MCP server in ~/.claude/.mcp.json
14
+ * 4. Ready to use β€” next Claude Code session will auto-load
15
+ */
16
+
17
+ import { execSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const HOME = process.env.HOME || process.env.USERPROFILE || "";
24
+ const IS_WIN = process.platform === "win32";
25
+ const INSTALL_DIR = IS_WIN
26
+ ? path.join(process.env.LOCALAPPDATA || path.join(HOME, "AppData", "Local"), "mcp-deliberation")
27
+ : path.join(HOME, ".local", "lib", "mcp-deliberation");
28
+ const MCP_CONFIG = path.join(HOME, ".claude", ".mcp.json");
29
+
30
+ /** Normalize path to forward slashes for JSON config (Windows backslash β†’ forward slash) */
31
+ function toForwardSlash(p) {
32
+ return p.replace(/\\/g, "/");
33
+ }
34
+
35
+ const FILES_TO_COPY = [
36
+ "index.js",
37
+ "observer.js",
38
+ "browser-control-port.js",
39
+ "degradation-state-machine.js",
40
+ "session-monitor.sh",
41
+ "session-monitor-win.js",
42
+ "package.json",
43
+ "package-lock.json",
44
+ ];
45
+
46
+ const DIRS_TO_COPY = ["selectors", "public", "skills"];
47
+
48
+ function log(msg) {
49
+ console.log(` ${msg}`);
50
+ }
51
+
52
+ function copyFileIfExists(src, dest) {
53
+ if (fs.existsSync(src)) {
54
+ fs.copyFileSync(src, dest);
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function copyDirRecursive(src, dest) {
61
+ if (!fs.existsSync(src)) return;
62
+ fs.mkdirSync(dest, { recursive: true });
63
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
64
+ const srcPath = path.join(src, entry.name);
65
+ const destPath = path.join(dest, entry.name);
66
+ if (entry.isDirectory()) {
67
+ copyDirRecursive(srcPath, destPath);
68
+ } else {
69
+ fs.copyFileSync(srcPath, destPath);
70
+ }
71
+ }
72
+ }
73
+
74
+ function install() {
75
+ console.log("\n🎯 Deliberation MCP Server β€” μ„€μΉ˜ μ‹œμž‘\n");
76
+
77
+ // Step 1: Create install directory
78
+ log("πŸ“ μ„€μΉ˜ 디렉토리 생성...");
79
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
80
+ log(` β†’ ${INSTALL_DIR}`);
81
+
82
+ // Step 2: Copy files
83
+ log("πŸ“¦ μ„œλ²„ 파일 볡사...");
84
+ let copied = 0;
85
+ for (const file of FILES_TO_COPY) {
86
+ if (copyFileIfExists(path.join(__dirname, file), path.join(INSTALL_DIR, file))) {
87
+ copied++;
88
+ }
89
+ }
90
+ for (const dir of DIRS_TO_COPY) {
91
+ const src = path.join(__dirname, dir);
92
+ if (fs.existsSync(src)) {
93
+ copyDirRecursive(src, path.join(INSTALL_DIR, dir));
94
+ copied++;
95
+ }
96
+ }
97
+ log(` β†’ ${copied}개 ν•­λͺ© 볡사 μ™„λ£Œ`);
98
+
99
+ // Step 3: Install dependencies
100
+ log("πŸ“₯ μ˜μ‘΄μ„± μ„€μΉ˜...");
101
+ try {
102
+ execSync("npm install --production --no-audit --no-fund", {
103
+ cwd: INSTALL_DIR,
104
+ stdio: "pipe",
105
+ });
106
+ log(" β†’ npm install μ™„λ£Œ");
107
+ } catch (err) {
108
+ log(` ⚠️ npm install μ‹€νŒ¨: ${err.message}`);
109
+ log(" μˆ˜λ™ μ‹€ν–‰: cd ~/.local/lib/mcp-deliberation && npm install");
110
+ }
111
+
112
+ // Step 4: Register MCP server
113
+ log("πŸ”§ Claude Code MCP μ„œλ²„ 등둝...");
114
+ const claudeDir = path.join(HOME, ".claude");
115
+ fs.mkdirSync(claudeDir, { recursive: true });
116
+
117
+ let mcpConfig = {};
118
+ if (fs.existsSync(MCP_CONFIG)) {
119
+ try {
120
+ mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
121
+ } catch {
122
+ mcpConfig = {};
123
+ }
124
+ }
125
+
126
+ if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
127
+
128
+ const alreadyRegistered = !!mcpConfig.mcpServers.deliberation;
129
+ mcpConfig.mcpServers.deliberation = {
130
+ command: "node",
131
+ args: [toForwardSlash(path.join(INSTALL_DIR, "index.js"))],
132
+ };
133
+
134
+ fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
135
+ log(alreadyRegistered
136
+ ? " β†’ κΈ°μ‘΄ 등둝 μ—…λ°μ΄νŠΈ μ™„λ£Œ"
137
+ : " β†’ μƒˆλ‘œ 등둝 μ™„λ£Œ");
138
+
139
+ // Step 5: Make session-monitor.sh executable
140
+ const monitorScript = path.join(INSTALL_DIR, "session-monitor.sh");
141
+ if (fs.existsSync(monitorScript)) {
142
+ try {
143
+ fs.chmodSync(monitorScript, 0o755);
144
+ } catch { /* ignore on Windows */ }
145
+ }
146
+
147
+ // Step 6: Preserve existing config
148
+ const configPath = path.join(INSTALL_DIR, "config.json");
149
+ if (!fs.existsSync(configPath)) {
150
+ fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
151
+ }
152
+
153
+ // Done
154
+ console.log("\nβœ… μ„€μΉ˜ μ™„λ£Œ!\n");
155
+ console.log(" λ‹€μŒ 단계:");
156
+ console.log(" 1. Claude Code μ„Έμ…˜μ„ μž¬μ‹œμž‘ν•˜μ„Έμš”");
157
+ console.log(" 2. \"ν† λ‘  μ‹œμž‘ν•΄\" λ˜λŠ” deliberation_start(topic: \"...\") 호좜");
158
+ console.log(" 3. 첫 μ‚¬μš© μ‹œ μ˜¨λ³΄λ”© μœ„μ €λ“œκ°€ κΈ°λ³Έ 섀정을 μ•ˆλ‚΄ν•©λ‹ˆλ‹€\n");
159
+ }
160
+
161
+ // Entry point
162
+ const args = process.argv.slice(2);
163
+ if (args.includes("--help") || args.includes("-h")) {
164
+ console.log(`
165
+ Deliberation MCP Server Installer
166
+
167
+ Usage:
168
+ npx @dmsdc-ai/aigentry-deliberation install
169
+ node install.js
170
+
171
+ Options:
172
+ --help, -h 이 도움말 ν‘œμ‹œ
173
+ --uninstall μ„œλ²„ 제거
174
+
175
+ μ„€μΉ˜ 경둜: ${INSTALL_DIR}
176
+ MCP μ„€μ •: ${MCP_CONFIG}
177
+ `);
178
+ } else if (args.includes("--uninstall") || args.includes("uninstall")) {
179
+ console.log("\nπŸ—‘οΈ Deliberation MCP Server 제거\n");
180
+
181
+ // Remove from MCP config
182
+ if (fs.existsSync(MCP_CONFIG)) {
183
+ try {
184
+ const mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
185
+ if (mcpConfig.mcpServers?.deliberation) {
186
+ delete mcpConfig.mcpServers.deliberation;
187
+ fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
188
+ log("MCP 등둝 ν•΄μ œ μ™„λ£Œ");
189
+ }
190
+ } catch { /* ignore */ }
191
+ }
192
+
193
+ // Remove install directory
194
+ if (fs.existsSync(INSTALL_DIR)) {
195
+ fs.rmSync(INSTALL_DIR, { recursive: true, force: true });
196
+ log("μ„€μΉ˜ 디렉토리 μ‚­μ œ μ™„λ£Œ");
197
+ }
198
+
199
+ console.log("\nβœ… 제거 μ™„λ£Œ. Claude Codeλ₯Ό μž¬μ‹œμž‘ν•˜μ„Έμš”.\n");
200
+ } else {
201
+ install();
202
+ }
package/observer.js ADDED
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deliberation Observer β€” SSE + Minimal Dashboard
4
+ *
5
+ * Usage:
6
+ * node observer.js [--port 3847]
7
+ * npx aigentry-deliberation --dashboard
8
+ *
9
+ * Provides:
10
+ * GET / β†’ HTML dashboard
11
+ * GET /api/sessions β†’ JSON list of active sessions
12
+ * GET /api/sessions/:id β†’ JSON session detail
13
+ * GET /api/sessions/:id/stream β†’ SSE real-time log stream
14
+ */
15
+
16
+ import http from "http";
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import os from "os";
20
+ import { fileURLToPath } from "url";
21
+ import { execSync } from "child_process";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const STATE_DIR = path.join(os.homedir(), ".local", "lib", "mcp-deliberation", "state");
25
+ const CONFIG_PATH = path.join(os.homedir(), ".local", "lib", "mcp-deliberation", "config.json");
26
+ const DEFAULT_CLI_CANDIDATES = ["claude", "codex", "gemini", "qwen", "chatgpt", "aider", "llm", "opencode", "cursor", "continue"];
27
+ const CLI_LABELS = {
28
+ claude: "Claude", codex: "Codex", gemini: "Gemini", qwen: "Qwen",
29
+ chatgpt: "ChatGPT", aider: "Aider", llm: "LLM (Simon)",
30
+ opencode: "OpenCode", cursor: "Cursor", continue: "Continue"
31
+ };
32
+ const DEFAULT_PORT = 3847;
33
+
34
+ function getProjectSlug() {
35
+ const cwd = process.cwd();
36
+ return path.basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
37
+ }
38
+
39
+ function listSessions(projectSlug) {
40
+ const sessionsDir = path.join(STATE_DIR, projectSlug || "", "sessions");
41
+ try {
42
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json"));
43
+ return files.map(f => {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), "utf-8"));
46
+ } catch { return null; }
47
+ }).filter(Boolean);
48
+ } catch { return []; }
49
+ }
50
+
51
+ function findSession(sessionId) {
52
+ // Search all project slugs
53
+ try {
54
+ const slugs = fs.readdirSync(STATE_DIR).filter(f => {
55
+ const stat = fs.statSync(path.join(STATE_DIR, f));
56
+ return stat.isDirectory();
57
+ });
58
+ for (const slug of slugs) {
59
+ const sessionsDir = path.join(STATE_DIR, slug, "sessions");
60
+ const filePath = path.join(sessionsDir, `${sessionId}.json`);
61
+ try {
62
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
63
+ } catch { continue; }
64
+ }
65
+ } catch {}
66
+ return null;
67
+ }
68
+
69
+ function getAllActiveSessions() {
70
+ const all = [];
71
+ try {
72
+ const slugs = fs.readdirSync(STATE_DIR).filter(f => {
73
+ try { return fs.statSync(path.join(STATE_DIR, f)).isDirectory(); } catch { return false; }
74
+ });
75
+ for (const slug of slugs) {
76
+ const sessions = listSessions(slug);
77
+ all.push(...sessions.filter(s => s.status === "active"));
78
+ }
79
+ } catch {}
80
+ return all;
81
+ }
82
+
83
+ // SSE connections per session
84
+ const sseClients = new Map();
85
+
86
+ function broadcastSessionUpdate(sessionId, event, data) {
87
+ const clients = sseClients.get(sessionId) || [];
88
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
89
+ clients.forEach(res => {
90
+ try { res.write(payload); } catch {}
91
+ });
92
+ }
93
+
94
+ // Poll for session changes
95
+ const sessionSnapshots = new Map();
96
+ function pollSessions() {
97
+ for (const [sessionId, clients] of sseClients.entries()) {
98
+ if (clients.length === 0) continue;
99
+ const session = findSession(sessionId);
100
+ if (!session) continue;
101
+ const prev = sessionSnapshots.get(sessionId);
102
+ const currentLogLen = session.log?.length || 0;
103
+ const prevLogLen = prev?.logLength || 0;
104
+
105
+ if (currentLogLen > prevLogLen) {
106
+ // New log entries
107
+ const newEntries = session.log.slice(prevLogLen);
108
+ for (const entry of newEntries) {
109
+ broadcastSessionUpdate(sessionId, "turn", entry);
110
+ }
111
+ }
112
+
113
+ if (prev?.status !== session.status) {
114
+ broadcastSessionUpdate(sessionId, "status", {
115
+ status: session.status,
116
+ current_speaker: session.current_speaker,
117
+ current_round: session.current_round,
118
+ });
119
+ }
120
+
121
+ sessionSnapshots.set(sessionId, {
122
+ logLength: currentLogLen,
123
+ status: session.status,
124
+ });
125
+ }
126
+ }
127
+
128
+ // HTML dashboard
129
+ function getDashboardHtml() {
130
+ const htmlPath = path.join(__dirname, "public", "index.html");
131
+ try {
132
+ return fs.readFileSync(htmlPath, "utf-8");
133
+ } catch {
134
+ return `<!DOCTYPE html><html><body><h1>Dashboard file not found</h1><p>Expected: ${htmlPath}</p></body></html>`;
135
+ }
136
+ }
137
+
138
+ function loadConfig() {
139
+ try {
140
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ function saveConfig(config) {
147
+ const dir = path.dirname(CONFIG_PATH);
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
150
+ }
151
+
152
+ function checkCliInstalled(name) {
153
+ try {
154
+ execSync(`which ${name}`, { stdio: 'ignore' });
155
+ return true;
156
+ } catch { return false; }
157
+ }
158
+
159
+ function fetchCdpTargets() {
160
+ return new Promise((resolve) => {
161
+ const req = http.get("http://localhost:9222/json", (res) => {
162
+ let data = "";
163
+ res.on("data", chunk => data += chunk);
164
+ res.on("end", () => {
165
+ try {
166
+ const targets = JSON.parse(data);
167
+ const llmPatterns = /claude|chatgpt|openai|gemini|bard|copilot|perplexity|deepseek|qwen/i;
168
+ const llmTabs = targets
169
+ .filter(t => t.type === "page" && (llmPatterns.test(t.url) || llmPatterns.test(t.title)))
170
+ .map(t => ({ title: t.title, url: t.url, id: t.id }));
171
+ resolve({ cdp_available: true, tabs: llmTabs });
172
+ } catch { resolve({ cdp_available: true, tabs: [] }); }
173
+ });
174
+ });
175
+ req.on("error", () => resolve({ cdp_available: false, tabs: [], message: "CDP not available. Launch Chrome with --remote-debugging-port=9222" }));
176
+ req.setTimeout(2000, () => { req.destroy(); resolve({ cdp_available: false, tabs: [], message: "CDP connection timeout" }); });
177
+ });
178
+ }
179
+
180
+ function createSession(topic, speakers, maxRounds) {
181
+ const id = `obs-${Date.now().toString(36)}`;
182
+ const projectSlug = getProjectSlug();
183
+ const sessionsDir = path.join(STATE_DIR, projectSlug, "sessions");
184
+
185
+ fs.mkdirSync(sessionsDir, { recursive: true });
186
+
187
+ const session = {
188
+ id,
189
+ topic: topic || "Untitled Discussion",
190
+ speakers: speakers || ["claude", "gemini"],
191
+ status: "active",
192
+ current_round: 1,
193
+ max_rounds: maxRounds || 3,
194
+ current_speaker: speakers?.[0] || "claude",
195
+ ordering_strategy: "cyclic",
196
+ log: [],
197
+ created_at: new Date().toISOString(),
198
+ created_by: "observer",
199
+ };
200
+
201
+ fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify(session, null, 2));
202
+ return session;
203
+ }
204
+
205
+ function computeStats() {
206
+ const stats = {
207
+ total_sessions: 0,
208
+ total_turns: 0,
209
+ speakers: {},
210
+ };
211
+
212
+ try {
213
+ const slugs = fs.readdirSync(STATE_DIR).filter(f => {
214
+ try { return fs.statSync(path.join(STATE_DIR, f)).isDirectory(); } catch { return false; }
215
+ });
216
+
217
+ for (const slug of slugs) {
218
+ const sessionsDir = path.join(STATE_DIR, slug, "sessions");
219
+ let files;
220
+ try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json")); } catch { continue; }
221
+
222
+ for (const file of files) {
223
+ try {
224
+ const session = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), "utf-8"));
225
+ stats.total_sessions++;
226
+
227
+ for (const entry of (session.log || [])) {
228
+ stats.total_turns++;
229
+ const sp = entry.speaker;
230
+ if (!sp) continue;
231
+
232
+ if (!stats.speakers[sp]) {
233
+ stats.speakers[sp] = { turns: 0, votes: { agree: 0, disagree: 0, conditional: 0 }, total_length: 0 };
234
+ }
235
+ stats.speakers[sp].turns++;
236
+ stats.speakers[sp].total_length += (entry.content || "").length;
237
+
238
+ for (const v of (entry.votes || [])) {
239
+ const key = v.toLowerCase();
240
+ if (key in stats.speakers[sp].votes) stats.speakers[sp].votes[key]++;
241
+ }
242
+ }
243
+ } catch { continue; }
244
+ }
245
+ }
246
+
247
+ for (const sp of Object.keys(stats.speakers)) {
248
+ const s = stats.speakers[sp];
249
+ s.avg_length = s.turns > 0 ? Math.round(s.total_length / s.turns) : 0;
250
+ delete s.total_length;
251
+ }
252
+ } catch {}
253
+
254
+ return stats;
255
+ }
256
+
257
+ // HTTP Server
258
+ function createServer(port) {
259
+ const server = http.createServer((req, res) => {
260
+ const url = new URL(req.url, `http://localhost:${port}`);
261
+ const pathname = url.pathname;
262
+
263
+ // CORS
264
+ res.setHeader("Access-Control-Allow-Origin", "*");
265
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
266
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
267
+
268
+ // OPTIONS preflight
269
+ if (req.method === "OPTIONS") {
270
+ res.writeHead(204);
271
+ res.end();
272
+ return;
273
+ }
274
+
275
+ // Routes
276
+ if (pathname === "/" || pathname === "/index.html") {
277
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
278
+ res.end(getDashboardHtml());
279
+ return;
280
+ }
281
+
282
+ if (pathname === "/api/sessions") {
283
+ const sessions = getAllActiveSessions();
284
+ const summary = sessions.map(s => ({
285
+ id: s.id,
286
+ topic: s.topic,
287
+ status: s.status,
288
+ current_round: s.current_round,
289
+ max_rounds: s.max_rounds,
290
+ current_speaker: s.current_speaker,
291
+ speakers: s.speakers,
292
+ log_count: s.log?.length || 0,
293
+ created: s.created,
294
+ }));
295
+ res.writeHead(200, { "Content-Type": "application/json" });
296
+ res.end(JSON.stringify(summary));
297
+ return;
298
+ }
299
+
300
+ if (pathname === "/api/sessions/start" && req.method === "POST") {
301
+ let body = "";
302
+ req.on("data", chunk => { body += chunk; });
303
+ req.on("end", () => {
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(body);
307
+ } catch {
308
+ res.writeHead(400, { "Content-Type": "application/json" });
309
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
310
+ return;
311
+ }
312
+ if (!parsed.topic) {
313
+ res.writeHead(400, { "Content-Type": "application/json" });
314
+ res.end(JSON.stringify({ error: "topic is required" }));
315
+ return;
316
+ }
317
+ try {
318
+ const session = createSession(parsed.topic, parsed.speakers, parsed.max_rounds);
319
+ res.writeHead(201, { "Content-Type": "application/json" });
320
+ res.end(JSON.stringify(session));
321
+ } catch (err) {
322
+ res.writeHead(500, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ error: String(err) }));
324
+ }
325
+ });
326
+ return;
327
+ }
328
+
329
+ const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
330
+ if (sessionMatch) {
331
+ const session = findSession(sessionMatch[1]);
332
+ if (!session) {
333
+ res.writeHead(404, { "Content-Type": "application/json" });
334
+ res.end(JSON.stringify({ error: "Session not found" }));
335
+ return;
336
+ }
337
+ res.writeHead(200, { "Content-Type": "application/json" });
338
+ res.end(JSON.stringify(session));
339
+ return;
340
+ }
341
+
342
+ const streamMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/stream$/);
343
+ if (streamMatch) {
344
+ const sessionId = streamMatch[1];
345
+ const session = findSession(sessionId);
346
+ if (!session) {
347
+ res.writeHead(404, { "Content-Type": "application/json" });
348
+ res.end(JSON.stringify({ error: "Session not found" }));
349
+ return;
350
+ }
351
+
352
+ // SSE setup
353
+ res.writeHead(200, {
354
+ "Content-Type": "text/event-stream",
355
+ "Cache-Control": "no-cache",
356
+ "Connection": "keep-alive",
357
+ });
358
+ res.write(`event: connected\ndata: ${JSON.stringify({ session_id: sessionId })}\n\n`);
359
+
360
+ // Send current state
361
+ res.write(`event: snapshot\ndata: ${JSON.stringify({
362
+ status: session.status,
363
+ current_speaker: session.current_speaker,
364
+ current_round: session.current_round,
365
+ max_rounds: session.max_rounds,
366
+ speakers: session.speakers,
367
+ log: session.log,
368
+ })}\n\n`);
369
+
370
+ // Register client
371
+ if (!sseClients.has(sessionId)) sseClients.set(sessionId, []);
372
+ sseClients.get(sessionId).push(res);
373
+ sessionSnapshots.set(sessionId, {
374
+ logLength: session.log?.length || 0,
375
+ status: session.status,
376
+ });
377
+
378
+ req.on("close", () => {
379
+ const clients = sseClients.get(sessionId) || [];
380
+ sseClients.set(sessionId, clients.filter(c => c !== res));
381
+ });
382
+ return;
383
+ }
384
+
385
+ if (pathname === "/api/config" && req.method === "GET") {
386
+ const config = loadConfig();
387
+ const enabledClis = Array.isArray(config.enabled_clis) ? config.enabled_clis : [];
388
+ res.writeHead(200, { "Content-Type": "application/json" });
389
+ res.end(JSON.stringify({
390
+ mode: enabledClis.length === 0 ? "auto-detect" : "config",
391
+ enabled_clis: enabledClis,
392
+ all_clis: DEFAULT_CLI_CANDIDATES,
393
+ updated: config.updated || null,
394
+ }));
395
+ return;
396
+ }
397
+
398
+ if (pathname === "/api/config" && req.method === "POST") {
399
+ let body = "";
400
+ req.on("data", chunk => { body += chunk; });
401
+ req.on("end", () => {
402
+ let parsed;
403
+ try {
404
+ parsed = JSON.parse(body);
405
+ } catch {
406
+ res.writeHead(400, { "Content-Type": "application/json" });
407
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
408
+ return;
409
+ }
410
+ const enabledClis = Array.isArray(parsed.enabled_clis) ? parsed.enabled_clis : [];
411
+ const config = {
412
+ enabled_clis: enabledClis,
413
+ updated: new Date().toISOString(),
414
+ };
415
+ saveConfig(config);
416
+ res.writeHead(200, { "Content-Type": "application/json" });
417
+ res.end(JSON.stringify({
418
+ mode: enabledClis.length === 0 ? "auto-detect" : "config",
419
+ enabled_clis: enabledClis,
420
+ all_clis: DEFAULT_CLI_CANDIDATES,
421
+ updated: config.updated,
422
+ }));
423
+ });
424
+ return;
425
+ }
426
+
427
+ if (pathname === "/api/cli-status" && req.method === "GET") {
428
+ const clis = DEFAULT_CLI_CANDIDATES.map(name => ({
429
+ name,
430
+ label: CLI_LABELS[name] || name,
431
+ installed: checkCliInstalled(name),
432
+ }));
433
+ res.writeHead(200, { "Content-Type": "application/json" });
434
+ res.end(JSON.stringify({ clis }));
435
+ return;
436
+ }
437
+
438
+ if (pathname === "/api/browser-tabs" && req.method === "GET") {
439
+ (async () => {
440
+ const tabs = await fetchCdpTargets();
441
+ res.writeHead(200, { "Content-Type": "application/json" });
442
+ res.end(JSON.stringify(tabs));
443
+ })().catch(err => {
444
+ res.writeHead(500, { "Content-Type": "application/json" });
445
+ res.end(JSON.stringify({ error: String(err) }));
446
+ });
447
+ return;
448
+ }
449
+
450
+ if (pathname === "/api/stats" && req.method === "GET") {
451
+ const stats = computeStats();
452
+ res.writeHead(200, { "Content-Type": "application/json" });
453
+ res.end(JSON.stringify(stats));
454
+ return;
455
+ }
456
+
457
+ res.writeHead(404, { "Content-Type": "application/json" });
458
+ res.end(JSON.stringify({ error: "Not found" }));
459
+ });
460
+
461
+ return server;
462
+ }
463
+
464
+ // Main
465
+ const port = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--port") || DEFAULT_PORT);
466
+ const server = createServer(port);
467
+
468
+ // Poll every 1 second
469
+ const pollInterval = setInterval(pollSessions, 1000);
470
+
471
+ server.listen(port, () => {
472
+ console.log(`Deliberation Observer running at http://localhost:${port}`);
473
+ console.log(` Dashboard: http://localhost:${port}/`);
474
+ console.log(` API: http://localhost:${port}/api/sessions`);
475
+ console.log(` SSE: http://localhost:${port}/api/sessions/{id}/stream`);
476
+ console.log(`\n Press Ctrl+C to stop.`);
477
+ });
478
+
479
+ process.on("SIGINT", () => {
480
+ clearInterval(pollInterval);
481
+ server.close();
482
+ process.exit(0);
483
+ });