// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json) // Sessions détachées (survivent à la déconnexion du client), permissions // relayées en cartes, monitoring Écosystème temps réel. import http from 'node:http'; import net from 'node:net'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon } from './monitor.js'; // Topologie vivante : où tourne chaque app Ka = registre mld (M1M32), jamais codé ici. import { initRegistry, refreshRegistry, getRegistry, getSites, getProjects, findProject, selfNode, sshTarget, topologyText, describe as describeRegistryEvent, ensureAllNodesTooling } from './registry.js'; import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js'; import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js'; import { execFile } from 'node:child_process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const APP_DIR = path.join(__dirname, '..'); const DATA_DIR = path.join(APP_DIR, 'data'); const TRANSCRIPTS_DIR = path.join(DATA_DIR, 'transcripts'); const PUBLIC_DIR = path.join(APP_DIR, 'public'); const HOME = os.homedir(); fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true }); // ---------- config ---------- const CONFIG_PATH = path.join(DATA_DIR, 'config.json'); if (!fs.existsSync(CONFIG_PATH)) { console.error('config.json manquant — exécuter: node server/set-password.js '); process.exit(1); } const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); const PORT = config.port || 3300; const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude'; const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node'; // toutes les variables de ~/.claude/.env (ANTHROPIC_API_KEY, APIFY_TOKEN, …) // sont injectées dans les runs claude locaux (les runs distants font // `export $(cat ~/.claude/.env | xargs)` côté nœud) function loadClaudeEnv() { const out = {}; try { for (const line of fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8').split('\n')) { const m = line.match(/^([A-Z0-9_]+)=(\S+)/); if (m) out[m[1]] = m[2]; } } catch {} return out; } const CLAUDE_ENV = loadClaudeEnv(); const ANTHROPIC_API_KEY = CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; // ---------- Upgradeur de prompt (Anthropic Messages API) ---------- const UPGRADER_KEY = config.anthropicKey || CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8'; // Connaissance du Groupe KA injectée dans l'upgradeur pour produire des prompts // Claude Code précis, conformes aux standards de l'écosystème. const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration-ka.com. Ta seule tâche : réécrire la demande brute de l'utilisateur en un prompt EXCELLENT destiné à Claude Code, qui s'exécute sur le nœud d'une app du Groupe KA. Tu ne réponds jamais à la demande toi-même, tu ne codes pas : tu produis UNIQUEMENT le prompt amélioré, prêt à être envoyé. # Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr) - lou-ka (lou-ka.com) : location immobilière/logements. immo-ka (immo-ka.com) : achat/vente immobilier. vrai-prix (vrai-prix.com) : comparateur de prix. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. sorti-ka (sorti-ka.com) : sorties/événements. job-ka (job-ka.com) : emplois. api-ka (api-ka.com) : API centrale + supervision des connecteurs. ka-stats (ka-stats.com) : Ka·Stats, l'observatoire de statistiques de l'écosystème — agrège les dashboards /api/stats/dashboard des sites (Node zéro-dépendance). groupe-ka.com : hub + KA ID (SSO). ka2/ka4/ka6 (ka2.bot/ka4.bot/ka6.bot) : agents gardiens autonomes des connecteurs (KA Guardian — un seul repo Python/FastAPI, process launchd com.kaX.guardian, PAS pm2). Marché cible : Québec (fr-CA). - EMPLACEMENTS : les nœuds, répertoires et process PM2 réels de chaque app sont fournis en fin de ce message (bloc « Emplacements ACTUELS », issu du registre mld) — ils changent au fil des migrations, ne jamais en citer de mémoire. - Architecture commune : back Node (souvent Express/Fastify), front React/Next ou Vite, données via connecteurs (scrapers) + SQLite/Postgres, PM2 pour les process, ngrok pour les domaines. Beaucoup d'apps ont un backend "sync"/crawler et une couche qualité (published/quarantaine). # Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent) - Remote-first : la source de vérité est le repo de PROD sur le nœud de déploiement ; on édite là, on build, on redémarre PM2, puis git add/commit/push origin main. origin = spbgit (git perso, git.spboucher.ai), PAS GitHub. - Après toute modif : rebuild si nécessaire + pm2 restart + vérifier le healthcheck/site + commit & push spbgit. Ne jamais laisser de modif non commitée/non déployée. - Standards UI Groupe KA : header/nav opaques fixes ; échelle de z-index par variables --z-* ; mobile-first, contenu jamais masqué par le clavier (dvh + safe-area) ; anti-zoom iOS (inputs 16px). Pages détail : l'ordre du DOM = l'ordre visuel identique mobile ET desktop (galerie → prix/adresse → description → inclusions → détails → analyses → carte → KA Scores → quartier), JAMAIS de réordonnancement via order:/column-reverse entre breakpoints. - Design system ka-ui : tokens/couleurs/footer centralisés (ecosystem.json). Toujours valider en mobile (simulateur/Playwright) après un changement d'UI. # Outils de scraping/recherche disponibles sur chaque nœud (variables dans ~/.claude/.env) - Serper (SERPER_API_KEY, recherche Google, gl:ca hl:fr) et Tavily (TAVILY_API_KEY, search/map/extract/crawl) pour DÉCOUVRIR des sources de connecteurs. - Scrapfly (SCRAPFLY_KEY) et Bright Data Web Unlocker (BRIGHTDATA_API_KEY, zone web_unlocker1) et Oxylabs (Realtime API OXYLABS_USER/PASS + proxies résidentiels OXYLABS_PROXY_* avec ciblage -cc-CA) pour l'anti-bot. - Apify : 13 acteurs maison gorgeous_thistle/ka- (instagram, tiktok, x, youtube, twitch, kick, fansly, onlyfans, facebook, threads, snapchat, discord, patreon) pour l'enrichissement multi-plateforme de crea-ka ; apify-cli installé (apify push pour builder). # Comment écrire le prompt amélioré 1. Écris en français, à l'impératif, adressé à Claude Code. 2. Commence par l'objectif clair, puis le périmètre précis (fichiers/zones probables), puis les critères d'acceptation vérifiables. 3. Ajoute les détails manquants que la demande sous-entend (edge cases, validation, mobile, i18n fr-CA) sans dénaturer l'intention. 4. Rappelle les étapes de clôture attendues (build, pm2 restart, healthcheck, commit+push spbgit) UNIQUEMENT si la tâche modifie du code. 5. Demande à Claude Code d'explorer/lire avant de modifier, et de vérifier son travail. 6. Reste concis et actionnable : pas de blabla, pas de sections inutiles. N'invente pas de faits spécifiques non fournis. 7. Sortie = le prompt amélioré SEUL, sans préambule ("Voici…"), sans guillemets englobants, sans commentaire méta.`; async function upgradePrompt(rawPrompt, project) { if (!UPGRADER_KEY) throw new Error('Clé Anthropic non configurée (config.anthropicKey).'); const ctx = project ? `\n\nContexte : la demande vise le projet "${project.name}" (déployé sur le nœud ${project.node || selfNode()}, répertoire ${project.dir}).` : ''; const resp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': UPGRADER_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }, body: JSON.stringify({ model: UPGRADER_MODEL, max_tokens: 1500, system: KA_KNOWLEDGE + '\n\n# ' + topologyText(), messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }], }), }); const data = await resp.json(); if (data.error) throw new Error(data.error.message || 'Erreur Anthropic'); const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim(); if (!text) throw new Error('Réponse vide du modèle'); return text; } // ---------- audit ---------- const AUDIT_PATH = path.join(DATA_DIR, 'audit.jsonl'); function audit(entry) { fs.appendFile(AUDIT_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', () => {}); } // ---------- sessions (auth) ---------- const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json'); let sessions = {}; try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {} function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); } const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000; const SESSION_MAX_MS = 30 * 24 * 3600 * 1000; function verifyPassword(pw) { const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex'); return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(config.passwordHash)); } function newSession() { const token = crypto.randomBytes(32).toString('hex'); sessions[token] = { created: Date.now(), last: Date.now() }; saveSessions(); return token; } function getSession(req) { const cookie = req.headers.cookie || ''; const m = cookie.match(/(?:^|;\s*)akid=([a-f0-9]{64})/); if (!m) return null; const s = sessions[m[1]]; if (!s) return null; const now = Date.now(); if (now - s.last > SESSION_IDLE_MS || now - s.created > SESSION_MAX_MS) { delete sessions[m[1]]; saveSessions(); return null; } s.last = now; return m[1]; } const loginFails = new Map(); function clientIp(req) { const xf = req.headers['x-forwarded-for']; return (xf ? String(xf).split(',')[0].trim() : req.socket.remoteAddress) || '?'; } function loginAllowed(ip) { const rec = loginFails.get(ip); if (!rec) return true; if (Date.now() - rec.first > 15 * 60 * 1000) { loginFails.delete(ip); return true; } return rec.count < 8; } function loginFailed(ip) { const rec = loginFails.get(ip) || { first: Date.now(), count: 0 }; rec.count++; loginFails.set(ip, rec); } // ---------- chats ---------- const CHATS_PATH = path.join(DATA_DIR, 'chats.json'); let chats = []; try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {} // migration v1 → v2 for (const c of chats) { if (!c.model) c.model = 'fable'; if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default'; if (!c.state) c.state = 'idle'; if (c.totalCost === undefined) c.totalCost = 0; if (!c.alwaysAllow) c.alwaysAllow = []; if (c.autoAllowAll === undefined) c.autoAllowAll = false; } function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); } function getChat(id) { return chats.find(c => c.id === id); } function publicChat(c) { return { id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName, model: c.model, permMode: c.permMode, state: c.state, autoAllowAll: !!c.autoAllowAll, claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null, totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0, batch: c.batch || null, created: c.created, updated: c.updated, }; } function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); } function appendTranscript(chatId, event) { fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {}); } function readTranscript(chatId) { try { return fs.readFileSync(transcriptPath(chatId), 'utf8') .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }) .filter(Boolean); } catch { return []; } } // ---------- prompt bank ---------- const PROMPTS_PATH = path.join(DATA_DIR, 'prompts.json'); let prompts = []; try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {} function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); } // ---------- projets (apps Groupe KA) ---------- // La liste des projets Claude Code (app → nœud → repo de prod) est DÉRIVÉE du registre // mld via registry.js (getProjects/findProject) : quand `mld move` déplace une app, la // prochaine session s'ouvre sur le bon nœud. Plus aucune table figée ici. // Orchestrateur : une seule session sur le nœud de la console qui pilote toutes les apps // via SSH, en gardant le contexte complet de la tâche (CLAUDE.md régénéré par registry.js). const ORCH_DIR = path.join(HOME, 'ka-orchestrator'); const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: ORCH_DIR }; function listProjects() { return [ { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: selfNode(), dir: ORCH_PROJECT.dir }, ...getProjects().map(p => ({ id: p.id, name: p.name, node: p.node || selfNode(), dir: p.dir })), ]; } function resolveProject(id) { if (id === 'ORCH') return ORCH_PROJECT; return findProject(id); } // Une conversation garde node/dir de sa création ; si l'app a déménagé depuis (registre), // on la rattache au nœud actuel et on repart d'une session Claude vierge (la session // `--resume` vivait sur l'ancien nœud). function realignChat(chat) { if (!chat || chat.projectId === 'ORCH') return false; const proj = resolveProject(chat.projectId); if (!proj) return false; if (proj.node === chat.node && proj.dir === chat.dir) return false; const from = chat.node || selfNode(), to = proj.node || selfNode(); chat.projectId = proj.id; chat.node = proj.node; chat.dir = proj.dir; chat.claudeSessionId = null; chat.contextTokens = 0; saveChats(); emitAndLog(chat.id, { type: 'notice', text: `📦 ${chat.projectName} a déménagé ${from} → ${to} (registre mld) : cette conversation suit l'app sur son nouveau nœud — nouvelle session Claude Code (l'ancien contexte vivait sur ${from}).` }); audit({ kind: 'chat_realigned', chatId: chat.id, project: chat.projectId, from, to }); return true; } // ---------- exécution Claude Code ---------- const MODELS = ['fable', 'opus', 'sonnet', 'haiku']; // mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions) const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' }; const DEFAULT_MODEL = 'fable'; const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass']; const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task'; const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; const orchSystem = () => `Tu es la session ORCHESTRATRICE multi-sites du Groupe KA, lancée sur ${selfNode()}. La tâche demandée peut concerner PLUSIEURS apps KA à la fois — tu gardes le contexte complet du début à la fin. Le fichier CLAUDE.md de ton répertoire (~/ka-orchestrator) contient la table app→nœud→répertoire de prod (GÉNÉRÉE depuis le registre mld, donc à jour) et la méthode : lis-le et suis-le. Tu modifies les repos de PROD à distance via SSH (ssh 'cd && …') ; les apps sur ${selfNode()} se font en local. Planifie avec TodoWrite, explore le pattern commun avant d'agir, applique app par app, et pour CHAQUE app modifiée : build si besoin + pm2 restart + healthcheck + git add/commit/push origin main (spbgit). Termine par un bilan (apps modifiées, commits, restes). Ne laisse aucune app en état incohérent.\n\n${topologyText()}`; const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; // chatId -> { proc, permToken, stderr, remote } const running = new Map(); // requestId -> { chatId, resolve, timer, input, toolKey } const pendingPerms = new Map(); function broadcast(chatId, msg) { const data = JSON.stringify(msg); for (const ws of wss.clients) { if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data); } } function broadcastAll(msg) { const data = JSON.stringify(msg); for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data); } function broadcastEco(msg) { const data = JSON.stringify(msg); for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data); } function emitAndLog(chatId, msg) { appendTranscript(chatId, msg); broadcast(chatId, msg); } function setChatState(chat, state) { chat.state = state; chat.updated = Date.now(); saveChats(); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); } function permKey(tool, input) { if (tool === 'Bash') { const first = String(input?.command || '').trim().split(/\s+/)[0] || '?'; return 'Bash:' + first; } return tool; } function startClaude(chat, prompt) { if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.'); const permToken = crypto.randomBytes(16).toString('hex'); const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL; const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default'; let proc; realignChat(chat); // l'app a-t-elle déménagé depuis la dernière fois ? (registre mld) const sysAppend = chat.projectId === 'ORCH' ? orchSystem() : SYSTEM_APPEND + '\n\n' + topologyText(); const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend]; if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); if (!chat.node) { // exécution locale (app déployée sur M3U96a) const args = [...baseArgs]; if (permMode === 'bypass') { args.push('--dangerously-skip-permissions'); } else { args.push('--permission-mode', permMode); const mcpCfg = { mcpServers: { adminka: { command: NODE_BIN, args: [path.join(__dirname, 'perm-mcp.js')], env: { ADMIN_KA_PERM_URL: `http://127.0.0.1:${PORT}/internal/perm`, ADMIN_KA_PERM_TOKEN: permToken, }, }, }, }; const cfgPath = path.join(DATA_DIR, `mcp-${chat.id}.json`); fs.writeFileSync(cfgPath, JSON.stringify(mcpCfg)); args.push('--mcp-config', cfgPath); args.push('--permission-prompt-tool', 'mcp__adminka__approve'); args.push('--allowedTools', ALLOWED_READONLY); } proc = spawn(CLAUDE_BIN, args, { cwd: chat.dir, env: { ...process.env, ...CLAUDE_ENV, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME, }, stdio: ['pipe', 'pipe', 'pipe'], detached: false, }); } else { // exécution distante : claude tourne SUR le nœud de déploiement de l'app, // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée). const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6']; let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' '); if (permMode === 'bypass') { cmd += ' --dangerously-skip-permissions'; } else { const revPort = 21000 + Math.floor(Math.random() * 3000); sshArgs.push('-R', `127.0.0.1:${revPort}:127.0.0.1:${PORT}`); const mcpCfg = { mcpServers: { adminka: { command: '/opt/homebrew/bin/node', args: ['/Users/simon-pierreboucher/.adminka/perm-mcp.js'], env: { ADMIN_KA_PERM_URL: `http://127.0.0.1:${revPort}/internal/perm`, ADMIN_KA_PERM_TOKEN: permToken, }, }, }, }; const cfgFile = `/tmp/adminka-mcp-${permToken}.json`; cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`; } const remoteCmd = [ `cd ${chat.dir}`, 'export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH', 'export $(cat ~/.claude/.env | xargs)', `echo $$ > /tmp/adminka-pid-${permToken}`, cmd, ].join(' && '); proc = spawn('ssh', [...sshArgs, sshTarget(chat.node), remoteCmd], { env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME }, stdio: ['pipe', 'pipe', 'pipe'], }); } proc.stdin.write(prompt); proc.stdin.end(); const state = { proc, permToken, stderr: '', remote: chat.node || null }; running.set(chat.id, state); audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || selfNode(), dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) }); emitAndLog(chat.id, { type: 'status', state: 'running' }); setChatState(chat, 'running'); let buf = ''; proc.stdout.on('data', (d) => { buf += d.toString('utf8'); let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (!line) continue; let ev; try { ev = JSON.parse(line); } catch { continue; } handleClaudeEvent(chat, ev); } }); proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); }); proc.on('close', (code) => { running.delete(chat.id); for (const [rid, p] of pendingPerms) { if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); } } if (code !== 0 && code !== null) { emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` }); } emitAndLog(chat.id, { type: 'status', state: 'idle' }); setChatState(chat, 'idle'); broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName }); }); proc.on('error', (err) => { running.delete(chat.id); emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message }); emitAndLog(chat.id, { type: 'status', state: 'idle' }); setChatState(chat, 'idle'); }); } function handleClaudeEvent(chat, ev) { if (ev.type === 'system' && ev.subtype === 'init') { if (ev.session_id) chat.claudeSessionId = ev.session_id; if (ev.model) chat.lastModel = ev.model; saveChats(); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); } if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { for (const block of ev.message.content) { if (block.type === 'tool_use') { audit({ kind: 'tool_use', chatId: chat.id, tool: block.name, input: JSON.stringify(block.input || {}).slice(0, 1000) }); } } } if (ev.type === 'result') { chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0); chat.turns = (chat.turns || 0) + (ev.num_turns || 0); const u = ev.usage || {}; chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); saveChats(); audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns }); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); } if (ev.type === 'stream_event') { broadcast(chat.id, { type: 'claude', event: ev }); } else { emitAndLog(chat.id, { type: 'claude', event: ev }); } } function interrupt(chatId) { const st = running.get(chatId); if (!st) return; audit({ kind: 'interrupt', chatId }); if (st.remote) { spawn('ssh', ['-o', 'BatchMode=yes', sshTarget(st.remote), `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], { env: { ...process.env, HOME }, stdio: 'ignore', }); } st.proc.kill('SIGTERM'); setTimeout(() => { try { st.proc.kill('SIGKILL'); } catch {} }, 5000); } // exécuter un script sur un nœud (local ou ssh) — pour les actions d'admin function runOnNode(node, script) { return new Promise((resolve) => { const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`; const local = node === selfNode(); const bin = local ? '/bin/bash' : 'ssh'; const args = local ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), full]; execFile(bin, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME } }, (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null })); }); } // ---------- HTTP ---------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.woff2': 'font/woff2' }; function readBody(req, limit = 512 * 1024) { return new Promise((resolve, reject) => { let data = ''; req.on('data', (c) => { data += c; if (data.length > limit) { reject(new Error('body trop gros')); req.destroy(); } }); req.on('end', () => resolve(data)); req.on('error', reject); }); } function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); } const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://x'); const p = url.pathname; if (p === '/healthz') { res.writeHead(200); return res.end('ok'); } // --- beacon analytics (public, sans auth) : v1 (pings 60 s) et v2 (pv/hb) --- if (p === '/collect') { try { recordHit(url.searchParams, req); } catch {} res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' }); return res.end(PIXEL); } // logos des sites (favicon/apple-touch-icon en cache) — public, léger if (p.startsWith('/icons/')) { const icon = getIcon(p.split('/')[2]); if (!icon) { res.writeHead(404); return res.end(); } res.writeHead(200, { 'Content-Type': icon.type, 'Cache-Control': 'public, max-age=86400' }); return res.end(icon.buf); } // --- endpoint interne de permission (long-poll par perm-mcp.js) --- if (p === '/internal/perm' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } let chatId = null; for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid; if (!chatId) return json(res, 403, { error: 'bad token' }); const chat = getChat(chatId); const toolKey = permKey(body.tool_name, body.input); // « Tout autoriser » activé → toute la session est auto-approuvée : la tâche // va au bout sans jamais attendre l'utilisateur if (chat && chat.autoAllowAll) { audit({ kind: 'permission_auto_all', chatId, tool: body.tool_name }); emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: 'tout autoriser', input: body.input }); return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} }); } // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation if (chat && (chat.alwaysAllow || []).includes(toolKey)) { audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey }); emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input }); return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} }); } const requestId = crypto.randomBytes(8).toString('hex'); const p2 = new Promise((resolve) => { const timer = setTimeout(() => { pendingPerms.delete(requestId); emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' }); if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" }); }, 30 * 60 * 1000); pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey }); }); audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) }); emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input }); if (chat) setChatState(chat, 'waiting_perm'); broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name }); const decision = await p2; return json(res, 200, decision); } // --- auth --- if (p === '/api/login' && req.method === 'POST') { const ip = clientIp(req); if (!loginAllowed(ip)) return json(res, 429, { error: 'Trop de tentatives — réessayer dans 15 minutes.' }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } if (typeof body.password !== 'string' || !verifyPassword(body.password)) { loginFailed(ip); audit({ kind: 'login_fail', ip }); return json(res, 401, { error: 'Mot de passe invalide.' }); } const token = newSession(); audit({ kind: 'login_ok', ip }); res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': `akid=${token}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax; Secure`, }); return res.end(JSON.stringify({ ok: true })); } if (p === '/api/logout' && req.method === 'POST') { const t = getSession(req); if (t) { delete sessions[t]; saveSessions(); } res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'akid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax; Secure' }); return res.end(JSON.stringify({ ok: true })); } // --- API authentifiée --- if (p.startsWith('/api/')) { if (!getSession(req)) return json(res, 401, { error: 'auth' }); if (p === '/api/me') { const r = getRegistry(); return json(res, 200, { ok: true, host: os.hostname(), node: selfNode(), version: '3.2.0', registry: { updated: r.updated, fetchedAt: r.fetchedAt, source: r.source, stale: r.stale, error: r.error, nApps: r.nApps, sites: r.sites.length, nodes: r.nodes } }); } if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); // --- registre mld (topologie vivante) --- if (p === '/api/registry' && req.method === 'GET') return json(res, 200, getRegistry()); if (p === '/api/registry/refresh' && req.method === 'POST') { audit({ kind: 'registry_refresh', ip: clientIp(req) }); const r = await refreshRegistry('manual'); return json(res, r.ok ? 200 : 502, { ...r, registry: getRegistry() }); } if (p === '/api/registry/tooling' && req.method === 'POST') { audit({ kind: 'registry_tooling', ip: clientIp(req) }); return json(res, 200, { nodes: await ensureAllNodesTooling({ force: true }) }); } if (p === '/api/chats' && req.method === 'GET') { const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); return json(res, 200, { chats: list }); } if (p.startsWith('/api/chats/') && req.method === 'DELETE') { const id = p.split('/')[3]; const chat = getChat(id); if (chat && !running.has(id)) { chats = chats.filter(c => c.id !== id); saveChats(); try { fs.unlinkSync(transcriptPath(id)); } catch {} return json(res, 200, { ok: true }); } return json(res, 400, { error: 'introuvable ou en cours' }); } // --- prompt bank --- if (p === '/api/prompts' && req.method === 'GET') { const list = [...prompts].sort((a, b) => (b.updated || 0) - (a.updated || 0)); return json(res, 200, { prompts: list }); } if (p === '/api/prompts' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } const text = String(body.text || '').trim(); if (!text) return json(res, 400, { error: 'texte vide' }); const pr = { id: crypto.randomBytes(6).toString('hex'), title: (String(body.title || '').trim() || text.slice(0, 48)), text, project: resolveProject(body.project) ? body.project : null, fav: !!body.fav, uses: 0, created: Date.now(), updated: Date.now(), }; prompts.push(pr); savePrompts(); audit({ kind: 'prompt_save', id: pr.id }); return json(res, 200, { prompt: pr }); } if (p.startsWith('/api/prompts/') && req.method === 'PUT') { const id = p.split('/')[3]; const pr = prompts.find(x => x.id === id); if (!pr) return json(res, 404, { error: 'introuvable' }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim(); if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48); if ('project' in body) pr.project = resolveProject(body.project) ? body.project : null; if (typeof body.fav === 'boolean') pr.fav = body.fav; if (body.bumpUse) pr.uses = (pr.uses || 0) + 1; pr.updated = Date.now(); savePrompts(); return json(res, 200, { prompt: pr }); } if (p.startsWith('/api/prompts/') && req.method === 'DELETE') { const id = p.split('/')[3]; prompts = prompts.filter(x => x.id !== id); savePrompts(); return json(res, 200, { ok: true }); } if (p === '/api/upgrade-prompt' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } const raw = String(body.prompt || '').trim(); if (!raw) return json(res, 400, { error: 'prompt vide' }); try { const project = resolveProject(body.project); const upgraded = await upgradePrompt(raw, project); audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length }); return json(res, 200, { upgraded }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(getSites())); if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30)); if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7)); if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(getSites())); if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies()); if (p === '/api/analytics/inspect') return json(res, 200, analyticsInspect({ ip: url.searchParams.get('ip'), vid: url.searchParams.get('vid'), ua: url.searchParams.get('ua') })); if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5)); if (p === '/api/analytics/config' && req.method === 'GET') return json(res, 200, analyticsConfig()); if (p === '/api/analytics/config' && req.method === 'PUT') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } audit({ kind: 'analytics_config', ip: clientIp(req) }); return json(res, 200, setAnalyticsConfig(body)); } if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); if (p === '/api/eco/nodes') return json(res, 200, ecoNodes()); if (p.startsWith('/api/eco/site/')) { const d = ecoSite(p.split('/')[4]); return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' }); } // --- actions d'administration par site (pm2 / logs / commits) --- if (p === '/api/admin/action' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } const site = getSites().find(s => s.app === body.app); const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null; if (!site || !action) return json(res, 400, { error: 'app ou action invalide' }); audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) }); let out; if (site.unit) { // sites sous launchd (KA Guardian sur M4M36) — pas de pm2 sur ce nœud const cmds = { restart: `launchctl kickstart -k gui/$(id -u)/${site.unit}`, stop: `launchctl unload ~/Library/LaunchAgents/${site.unit}.plist`, start: `launchctl load ~/Library/LaunchAgents/${site.unit}.plist`, }; out = await runOnNode(site.node, `${cmds[action]} && sleep 1 && (launchctl list | awk '$3=="${site.unit}"{print ($1=="-")?"arrêté":"online"; f=1} END{if(!f)print "déchargé"}')`); } else { out = await runOnNode(site.node, `pm2 ${action} ${site.pm2} && pm2 jlist | python3 -c "import json,sys; l=json.load(sys.stdin); p=[x for x in l if x['name']=='${site.pm2}']; print(p[0]['pm2_env']['status'] if p else 'introuvable')"`); } return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) }); } if (p.startsWith('/api/admin/logs/')) { const site = getSites().find(s => s.app === p.split('/')[4]); if (!site) return json(res, 404, { error: 'site inconnu' }); const out = site.log ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`) : await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`); return json(res, 200, { logs: out.stdout || out.error || '(vide)' }); } if (p.startsWith('/api/admin/commits/')) { const site = getSites().find(s => s.app === p.split('/')[4]); if (!site) return json(res, 404, { error: 'site inconnu' }); const out = await runOnNode(site.node, `cd ${site.repoDir || site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`); return json(res, 200, { text: out.stdout || out.error || '(vide)' }); } // --- Social : publication automatique + a la demande --- if (p === '/api/social/vncpw' && req.method === 'GET') return json(res, 200, { password: config.vncPassword || '492592' }); if (p === '/api/social/state' && req.method === 'GET') { return json(res, 200, { state: socialState(), log: socialLog(40) }); } if (p === '/api/social/auto' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } audit({ kind: 'social_auto', on: !!body.on, ip: clientIp(req) }); return json(res, 200, { state: setAuto(!!body.on) }); } if (p === '/api/social/login' && req.method === 'GET') { try { return json(res, 200, { login: await fbLoginState() }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/social/generate' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } try { const draft = await generateDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() }); audit({ kind: 'social_generate', site: draft.insight.site }); return json(res, 200, { draft }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/social/publish' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } try { await publishDraft({ caption: String(body.caption || ''), image: cardPath(body.image || '') }); audit({ kind: 'social_publish', ip: clientIp(req) }); return json(res, 200, { ok: true }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/social/run' && req.method === 'POST') { audit({ kind: 'social_run_now', ip: clientIp(req) }); const r = await runAutoCycle('manuel'); return json(res, r.ok ? 200 : 502, r); } if (p.startsWith('/api/social/card/') && req.method === 'GET') { const fp = cardPath(p.split('/')[4] || ''); if (!fp) { res.writeHead(404); return res.end(); } res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' }); return res.end(fs.readFileSync(fp)); } if (p === '/api/social/reel/generate' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } try { const draft = await generateReelDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() }); audit({ kind: 'social_reel_generate', site: draft.insight.site }); return json(res, 200, { draft }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/social/reel/publish' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } try { await publishReelDraft({ caption: String(body.caption || ''), video: reelPath(body.video || '') }); audit({ kind: 'social_reel_publish', ip: clientIp(req) }); return json(res, 200, { ok: true }); } catch (e) { return json(res, 502, { error: e.message }); } } if (p === '/api/social/reel/run' && req.method === 'POST') { audit({ kind: 'social_reel_run', ip: clientIp(req) }); const r = await runReelCycle('reel-manuel'); return json(res, r.ok ? 200 : 502, r); } if (p === '/api/social/gallery' && req.method === 'GET') return json(res, 200, { items: getGallery(), gen: genBatchStatus() }); if (p === '/api/social/reel/genbatch' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; } const sites = (Array.isArray(body.sites) && body.sites.length) ? body.sites : REEL_BATCH_DEFAULT; audit({ kind: 'social_genbatch', n: sites.length, ip: clientIp(req) }); const r = startGenBatch(sites); return json(res, r.started ? 200 : 409, r); } if (p === '/api/social/reel/batch' && req.method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; } const sites = (Array.isArray(body.sites) && body.sites.length) ? body.sites : REEL_BATCH_DEFAULT; audit({ kind: 'social_reel_batch', n: sites.length, ip: clientIp(req) }); const r = startReelBatch(sites); return json(res, r.started ? 200 : 409, r); } if (p === '/api/social/reel/batchstatus' && req.method === 'GET') return json(res, 200, batchStatus()); if (p.startsWith('/api/social/reel/') && req.method === 'GET') { const fp = reelPath(p.split('/')[4] || ''); if (!fp) { res.writeHead(404); return res.end(); } const stat = fs.statSync(fp); res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': stat.size, 'Cache-Control': 'no-cache' }); return res.end(fs.readFileSync(fp)); } return json(res, 404, { error: 'not found' }); } // --- statique --- let file = p === '/' ? '/index.html' : p; file = path.normalize(file).replace(/^(\.\.[/\\])+/, ''); const full = path.join(PUBLIC_DIR, file); if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end(); } fs.readFile(full, (err, data) => { if (err) { res.writeHead(404); return res.end('not found'); } res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream', 'Cache-Control': full.includes('/vendor/') ? 'public, max-age=86400' : 'no-cache' }); res.end(data); }); }); // ---------- WebSocket ---------- const wss = new WebSocketServer({ noServer: true }); // --- pont VNC : WebSocket (noVNC dans l'admin) <-> Partage d'écran du nœud (127.0.0.1:5900) --- const vncWss = new WebSocketServer({ noServer: true }); vncWss.on('connection', (ws) => { const tcp = net.connect(5900, '127.0.0.1'); tcp.on('data', (d) => { if (ws.readyState === 1) ws.send(d); }); tcp.on('close', () => { try { ws.close(); } catch {} }); tcp.on('error', () => { try { ws.close(); } catch {} }); ws.on('message', (d) => { try { tcp.write(d); } catch {} }); ws.on('close', () => { try { tcp.end(); } catch {} }); ws.on('error', () => { try { tcp.destroy(); } catch {} }); }); server.on('upgrade', (req, socket, head) => { const url = new URL(req.url, 'http://x'); if (!getSession(req)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); } if (url.pathname === '/ws') { wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); } else if (url.pathname === '/vncws') { vncWss.handleUpgrade(req, socket, head, (ws) => vncWss.emit('connection', ws, req)); } else { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); } }); wss.on('connection', (ws) => { ws.chatId = null; ws.eco = false; ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } try { handleWsMessage(ws, msg); } catch (e) { ws.send(JSON.stringify({ type: 'error', message: e.message })); } }); // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours. }); setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { ws.terminate(); continue; } ws.isAlive = false; ws.ping(); } }, 30000); function handleWsMessage(ws, msg) { if (msg.type === 'open') { const chat = getChat(msg.chatId); if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' })); ws.chatId = chat.id; // rejoue tout l'historique persisté puis raccroche le live ws.send(JSON.stringify({ type: 'history', chat: publicChat(chat), events: readTranscript(chat.id), running: running.has(chat.id), })); return; } if (msg.type === 'start') { const prompt = String(msg.prompt || '').trim(); if (!prompt) return; let chat = msg.chatId ? getChat(msg.chatId) : null; if (!chat) { const proj = resolveProject(msg.project) || getProjects()[0]; if (!proj) { ws.send(JSON.stringify({ type: 'error', message: 'Aucun projet connu : le registre mld est indisponible (voir Réglages → Registre).' })); return; } chat = { id: crypto.randomBytes(8).toString('hex'), title: prompt.slice(0, 60), projectId: proj.id, projectName: proj.name, node: proj.node, dir: proj.dir, model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL, permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default', claudeSessionId: null, state: 'idle', totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [], created: Date.now(), updated: Date.now(), }; chats.push(chat); saveChats(); ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) })); } else { if (MODELS.includes(msg.model)) chat.model = msg.model; if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; saveChats(); } ws.chatId = chat.id; emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() }); startClaude(chat, prompt); return; } if (msg.type === 'start_all') { // diffuse le MÊME prompt à TOUTES les apps KA en parallèle (1 session/app) const prompt = String(msg.prompt || '').trim(); if (!prompt) return; const model = MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL; const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default'; const created = []; const stamp = new Date().toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const projectsNow = getProjects(); for (const proj of projectsNow) { const chat = { id: crypto.randomBytes(8).toString('hex'), title: '🌐 ' + prompt.slice(0, 46), projectId: proj.id, projectName: proj.name, node: proj.node, dir: proj.dir, model, permMode, claudeSessionId: null, state: 'idle', totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [], batch: stamp, created: Date.now(), updated: Date.now(), }; chats.push(chat); emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() }); try { startClaude(chat, prompt); created.push(chat.id); } catch (e) { emitAndLog(chat.id, { type: 'error', message: e.message }); } } saveChats(); audit({ kind: 'start_all', count: created.length, model, permMode, prompt: prompt.slice(0, 500) }); ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: projectsNow.length })); return; } if (msg.type === 'set_opts') { const chat = getChat(msg.chatId); if (!chat) return; if (MODELS.includes(msg.model)) chat.model = msg.model; if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; if (typeof msg.autoAllowAll === 'boolean') chat.autoAllowAll = msg.autoAllowAll; saveChats(); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); return; } if (msg.type === 'clear') { const chat = getChat(msg.chatId); if (!chat || running.has(chat.id)) return; chat.claudeSessionId = null; chat.contextTokens = 0; saveChats(); emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' }); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); return; } if (msg.type === 'perm') { const pending = pendingPerms.get(msg.requestId); if (!pending) return; clearTimeout(pending.timer); pendingPerms.delete(msg.requestId); const chat = getChat(pending.chatId); if (msg.allow && msg.allowAll && chat) { // « Tout autoriser » : plus AUCUNE permission ne sera demandée pour cette // conversation — le serveur approuve tout jusqu'à la fin de la tâche chat.autoAllowAll = true; saveChats(); emitAndLog(chat.id, { type: 'notice', text: '🔓 Tout autoriser activé — la tâche ira au bout sans autre demande de permission.' }); broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); } if (msg.allow && msg.always && chat) { if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey); saveChats(); } audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); pending.resolve(msg.allow ? { behavior: 'allow', updatedInput: pending.input || {} } : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" }); return; } if (msg.type === 'interrupt') { if (msg.chatId) interrupt(msg.chatId); else if (ws.chatId) interrupt(ws.chatId); return; } if (msg.type === 'eco_sub') { ws.eco = true; return; } if (msg.type === 'eco_unsub') { ws.eco = false; return; } } // ---------- registre mld (topologie vivante) — AVANT le monitoring, qui en dépend ---------- await initRegistry({ dataDir: DATA_DIR, orchestratorDocPath: path.join(ORCH_DIR, 'CLAUDE.md'), onChange: (events) => { for (const ev of events) { audit({ kind: 'registry_' + ev.kind, app: ev.app || null, from: ev.from || null, to: ev.to || null }); // alerte visible partout (bandeau) : une app a bougé, la console suit broadcastAll({ type: 'eco_alert', kind: 'registry', site: ev.app || 'admin-ka', reason: describeRegistryEvent(ev), ts: Date.now() }); } // les conversations rattachées à une app déplacée seront réalignées à leur prochain tour (realignChat) broadcastAll({ type: 'projects', projects: listProjects() }); }, }); // ---------- monitoring Écosystème ---------- startMonitor({ dataDir: DATA_DIR, onEvent: (ev) => { if (ev.type === 'eco_alert') broadcastAll(ev); // alertes → tout le monde else broadcastEco(ev); // ticks → abonnés à l'onglet }, }); // module Social : publication automatique horaire + génération sur demande initSocial( { anthropicKey: UPGRADER_KEY, upgraderModel: UPGRADER_MODEL, socialModel: config.socialModel, socialIntervalMin: config.socialIntervalMin, reelEvery: config.reelEvery }, (line) => broadcastAll({ type: 'social_log', line }), ); initAnalytics(); // au démarrage, les chats marqués "running" par un ancien processus sont orphelins for (const c of chats) if (c.state !== 'idle') c.state = 'idle'; saveChats(); server.listen(PORT, '::', () => { console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`); });