1// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json)2// Sessions détachées (survivent à la déconnexion du client), permissions3// relayées en cartes, monitoring Écosystème temps réel.4import http from 'node:http';5import net from 'node:net';6import crypto from 'node:crypto';7import fs from 'node:fs';8import path from 'node:path';9import os from 'node:os';10import { spawn } from 'node:child_process';11import { fileURLToPath } from 'node:url';12import { WebSocketServer } from 'ws';13import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';14import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js';15import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js';16import { execFile } from 'node:child_process';1718const __dirname = path.dirname(fileURLToPath(import.meta.url));19const APP_DIR = path.join(__dirname, '..');20const DATA_DIR = path.join(APP_DIR, 'data');21const TRANSCRIPTS_DIR = path.join(DATA_DIR, 'transcripts');22const PUBLIC_DIR = path.join(APP_DIR, 'public');23const HOME = os.homedir();2425fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });2627// ---------- config ----------28const CONFIG_PATH = path.join(DATA_DIR, 'config.json');29if (!fs.existsSync(CONFIG_PATH)) {30 console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>');31 process.exit(1);32}33const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));34const PORT = config.port || 3300;35const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude';36const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node';3738// toutes les variables de ~/.claude/.env (ANTHROPIC_API_KEY, APIFY_TOKEN, …)39// sont injectées dans les runs claude locaux (les runs distants font40// `export $(cat ~/.claude/.env | xargs)` côté nœud)41function loadClaudeEnv() {42 const out = {};43 try {44 for (const line of fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8').split('\n')) {45 const m = line.match(/^([A-Z0-9_]+)=(\S+)/);46 if (m) out[m[1]] = m[2];47 }48 } catch {}49 return out;50}51const CLAUDE_ENV = loadClaudeEnv();52const ANTHROPIC_API_KEY = CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';5354// ---------- Upgradeur de prompt (Anthropic Messages API) ----------55const UPGRADER_KEY = config.anthropicKey || CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';56const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8';5758// Connaissance du Groupe KA injectée dans l'upgradeur pour produire des prompts59// Claude Code précis, conformes aux standards de l'écosystème.60const 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é.6162# Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr)63- lou-ka (lou-ka.com) : location immobilière/logements. rent-ka (rent-ka.com) : locations résidentielles Canada hors Québec, site EN. immo-ka (immo-ka.com) : achat/vente immobilier. vrai-prix (vrai-prix.com) : comparateur de prix. valoplex : immobilier/valorisation. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. ka-stats (ka-stats.com) : Ka·Stats, l'observatoire de statistiques de l'écosystème — agrège les dashboards /api/stats/dashboard des 12 sites (Node zéro-dépendance, nœud M1M32, pm2 ka-stats + ka-stats-ngrok, dir ~/apps/ka-stats). 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 sur M4M36, process launchd com.kaX.guardian, PAS pm2). Marché cible : Québec (fr-CA).64- 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).6566# Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent)67- 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.68- Après toute modif : rebuild si nécessaire + pm2 restart <app> + vérifier le healthcheck/site + commit & push spbgit. Ne jamais laisser de modif non commitée/non déployée.69- 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.70- Design system ka-ui : tokens/couleurs/footer centralisés (ecosystem.json). Toujours valider en mobile (simulateur/Playwright) après un changement d'UI.7172# Outils de scraping/recherche disponibles sur chaque nœud (variables dans ~/.claude/.env)73- 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.74- 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.75- Apify : 13 acteurs maison gorgeous_thistle/ka-<plateforme> (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).7677# Comment écrire le prompt amélioré781. Écris en français, à l'impératif, adressé à Claude Code.792. Commence par l'objectif clair, puis le périmètre précis (fichiers/zones probables), puis les critères d'acceptation vérifiables.803. Ajoute les détails manquants que la demande sous-entend (edge cases, validation, mobile, i18n fr-CA) sans dénaturer l'intention.814. Rappelle les étapes de clôture attendues (build, pm2 restart, healthcheck, commit+push spbgit) UNIQUEMENT si la tâche modifie du code.825. Demande à Claude Code d'explorer/lire avant de modifier, et de vérifier son travail.836. Reste concis et actionnable : pas de blabla, pas de sections inutiles. N'invente pas de faits spécifiques non fournis.847. Sortie = le prompt amélioré SEUL, sans préambule ("Voici…"), sans guillemets englobants, sans commentaire méta.`;8586async function upgradePrompt(rawPrompt, project) {87 if (!UPGRADER_KEY) throw new Error('Clé Anthropic non configurée (config.anthropicKey).');88 const ctx = project ? `\n\nContexte : la demande vise le projet "${project.name}" (déployé sur le nœud ${project.node || SELF_NODE}, répertoire ${project.dir}).` : '';89 const resp = await fetch('https://api.anthropic.com/v1/messages', {90 method: 'POST',91 headers: {92 'x-api-key': UPGRADER_KEY,93 'anthropic-version': '2023-06-01',94 'content-type': 'application/json',95 },96 body: JSON.stringify({97 model: UPGRADER_MODEL,98 max_tokens: 1500,99 system: KA_KNOWLEDGE,100 messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }],101 }),102 });103 const data = await resp.json();104 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');105 const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();106 if (!text) throw new Error('Réponse vide du modèle');107 return text;108}109110// ---------- audit ----------111const AUDIT_PATH = path.join(DATA_DIR, 'audit.jsonl');112function audit(entry) {113 fs.appendFile(AUDIT_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', () => {});114}115116// ---------- sessions (auth) ----------117const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json');118let sessions = {};119try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {}120function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); }121const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000;122const SESSION_MAX_MS = 30 * 24 * 3600 * 1000;123124function verifyPassword(pw) {125 const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex');126 return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(config.passwordHash));127}128function newSession() {129 const token = crypto.randomBytes(32).toString('hex');130 sessions[token] = { created: Date.now(), last: Date.now() };131 saveSessions();132 return token;133}134function getSession(req) {135 const cookie = req.headers.cookie || '';136 const m = cookie.match(/(?:^|;\s*)akid=([a-f0-9]{64})/);137 if (!m) return null;138 const s = sessions[m[1]];139 if (!s) return null;140 const now = Date.now();141 if (now - s.last > SESSION_IDLE_MS || now - s.created > SESSION_MAX_MS) {142 delete sessions[m[1]]; saveSessions(); return null;143 }144 s.last = now;145 return m[1];146}147148const loginFails = new Map();149function clientIp(req) {150 const xf = req.headers['x-forwarded-for'];151 return (xf ? String(xf).split(',')[0].trim() : req.socket.remoteAddress) || '?';152}153function loginAllowed(ip) {154 const rec = loginFails.get(ip);155 if (!rec) return true;156 if (Date.now() - rec.first > 15 * 60 * 1000) { loginFails.delete(ip); return true; }157 return rec.count < 8;158}159function loginFailed(ip) {160 const rec = loginFails.get(ip) || { first: Date.now(), count: 0 };161 rec.count++; loginFails.set(ip, rec);162}163164// ---------- chats ----------165const CHATS_PATH = path.join(DATA_DIR, 'chats.json');166let chats = [];167try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {}168// migration v1 → v2169for (const c of chats) {170 if (!c.model) c.model = 'fable';171 if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default';172 if (!c.state) c.state = 'idle';173 if (c.totalCost === undefined) c.totalCost = 0;174 if (!c.alwaysAllow) c.alwaysAllow = [];175 if (c.autoAllowAll === undefined) c.autoAllowAll = false;176}177function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); }178function getChat(id) { return chats.find(c => c.id === id); }179function publicChat(c) {180 return {181 id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName,182 model: c.model, permMode: c.permMode, state: c.state, autoAllowAll: !!c.autoAllowAll,183 claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null,184 totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0,185 batch: c.batch || null,186 created: c.created, updated: c.updated,187 };188}189function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); }190function appendTranscript(chatId, event) {191 fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {});192}193function readTranscript(chatId) {194 try {195 return fs.readFileSync(transcriptPath(chatId), 'utf8')196 .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } })197 .filter(Boolean);198 } catch { return []; }199}200201// ---------- prompt bank ----------202const PROMPTS_PATH = path.join(DATA_DIR, 'prompts.json');203let prompts = [];204try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {}205function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); }206207// ---------- projets (12 apps Groupe KA) ----------208const SELF_NODE = 'M3U96a';209const PROJECTS = [210 { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' },211 { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' },212 { id: 'rent-ka@M4M36', name: 'Rent·Ka', node: 'M4M36', dir: '~/apps/rent-ka' },213 { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' },214 { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },215 { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },216 { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },217 { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },218 { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },219 { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },220 { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },221 { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },222 { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },223 { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },224 { id: 'ka-stats@M1M32', name: 'Ka·Stats', node: 'M1M32', dir: '~/apps/ka-stats' },225 // Agents gardiens KA Guardian — 3 sites, 1 seul repo sur M4M36 (launchd, pas pm2)226 { id: 'ka2@M4M36', name: 'KA·2 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },227 { id: 'ka4@M4M36', name: 'KA·4 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },228 { id: 'ka6@M4M36', name: 'KA·6 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },229];230// Orchestrateur : une seule session sur M3U96a qui pilote les 13 apps via SSH,231// en gardant le contexte complet de la tâche.232const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: path.join(HOME, 'ka-orchestrator') };233function listProjects() {234 return [235 { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: SELF_NODE, dir: ORCH_PROJECT.dir },236 ...PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })),237 ];238}239function findProject(id) {240 if (id === 'ORCH') return ORCH_PROJECT;241 return PROJECTS.find(p => p.id === id) || null;242}243244// ---------- exécution Claude Code ----------245const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];246// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions)247const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };248const DEFAULT_MODEL = 'fable';249const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass'];250const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task';251252const 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 <app> (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.`;253254const ORCH_SYSTEM = `Tu es la session ORCHESTRATRICE multi-sites du Groupe KA, lancée sur M3U96a. 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 et la méthode : lis-le et suis-le. Tu modifies les repos de PROD à distance via SSH (ssh <nœud> 'cd <dir> && …') ; les apps sur M3U96a 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.`;255256const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;257258// chatId -> { proc, permToken, stderr, remote }259const running = new Map();260// requestId -> { chatId, resolve, timer, input, toolKey }261const pendingPerms = new Map();262263function broadcast(chatId, msg) {264 const data = JSON.stringify(msg);265 for (const ws of wss.clients) {266 if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data);267 }268}269function broadcastAll(msg) {270 const data = JSON.stringify(msg);271 for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data);272}273function broadcastEco(msg) {274 const data = JSON.stringify(msg);275 for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data);276}277function emitAndLog(chatId, msg) {278 appendTranscript(chatId, msg);279 broadcast(chatId, msg);280}281function setChatState(chat, state) {282 chat.state = state;283 chat.updated = Date.now();284 saveChats();285 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });286}287288function permKey(tool, input) {289 if (tool === 'Bash') {290 const first = String(input?.command || '').trim().split(/\s+/)[0] || '?';291 return 'Bash:' + first;292 }293 return tool;294}295296function startClaude(chat, prompt) {297 if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.');298 const permToken = crypto.randomBytes(16).toString('hex');299 const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL;300 const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';301 let proc;302303 const sysAppend = chat.projectId === 'ORCH' ? ORCH_SYSTEM : SYSTEM_APPEND;304 const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',305 '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend];306 if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);307308 if (!chat.node) {309 // exécution locale (app déployée sur M3U96a)310 const args = [...baseArgs];311 if (permMode === 'bypass') {312 args.push('--dangerously-skip-permissions');313 } else {314 args.push('--permission-mode', permMode);315 const mcpCfg = {316 mcpServers: {317 adminka: {318 command: NODE_BIN,319 args: [path.join(__dirname, 'perm-mcp.js')],320 env: {321 ADMIN_KA_PERM_URL: `http://127.0.0.1:${PORT}/internal/perm`,322 ADMIN_KA_PERM_TOKEN: permToken,323 },324 },325 },326 };327 const cfgPath = path.join(DATA_DIR, `mcp-${chat.id}.json`);328 fs.writeFileSync(cfgPath, JSON.stringify(mcpCfg));329 args.push('--mcp-config', cfgPath);330 args.push('--permission-prompt-tool', 'mcp__adminka__approve');331 args.push('--allowedTools', ALLOWED_READONLY);332 }333 proc = spawn(CLAUDE_BIN, args, {334 cwd: chat.dir,335 env: {336 ...process.env, ...CLAUDE_ENV,337 PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME,338 },339 stdio: ['pipe', 'pipe', 'pipe'],340 detached: false,341 });342 } else {343 // exécution distante : claude tourne SUR le nœud de déploiement de l'app,344 // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée).345 const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6'];346 let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' ');347 if (permMode === 'bypass') {348 cmd += ' --dangerously-skip-permissions';349 } else {350 const revPort = 21000 + Math.floor(Math.random() * 3000);351 sshArgs.push('-R', `127.0.0.1:${revPort}:127.0.0.1:${PORT}`);352 const mcpCfg = {353 mcpServers: {354 adminka: {355 command: '/opt/homebrew/bin/node',356 args: ['/Users/simon-pierreboucher/.adminka/perm-mcp.js'],357 env: {358 ADMIN_KA_PERM_URL: `http://127.0.0.1:${revPort}/internal/perm`,359 ADMIN_KA_PERM_TOKEN: permToken,360 },361 },362 },363 };364 const cfgFile = `/tmp/adminka-mcp-${permToken}.json`;365 cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`;366 }367 const remoteCmd = [368 `cd ${chat.dir}`,369 'export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH',370 'export $(cat ~/.claude/.env | xargs)',371 `echo $$ > /tmp/adminka-pid-${permToken}`,372 cmd,373 ].join(' && ');374 proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], {375 env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },376 stdio: ['pipe', 'pipe', 'pipe'],377 });378 }379 proc.stdin.write(prompt);380 proc.stdin.end();381382 const state = { proc, permToken, stderr: '', remote: chat.node || null };383 running.set(chat.id, state);384 audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });385 emitAndLog(chat.id, { type: 'status', state: 'running' });386 setChatState(chat, 'running');387388 let buf = '';389 proc.stdout.on('data', (d) => {390 buf += d.toString('utf8');391 let idx;392 while ((idx = buf.indexOf('\n')) >= 0) {393 const line = buf.slice(0, idx).trim();394 buf = buf.slice(idx + 1);395 if (!line) continue;396 let ev;397 try { ev = JSON.parse(line); } catch { continue; }398 handleClaudeEvent(chat, ev);399 }400 });401 proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); });402 proc.on('close', (code) => {403 running.delete(chat.id);404 for (const [rid, p] of pendingPerms) {405 if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); }406 }407 if (code !== 0 && code !== null) {408 emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` });409 }410 emitAndLog(chat.id, { type: 'status', state: 'idle' });411 setChatState(chat, 'idle');412 broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName });413 });414 proc.on('error', (err) => {415 running.delete(chat.id);416 emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message });417 emitAndLog(chat.id, { type: 'status', state: 'idle' });418 setChatState(chat, 'idle');419 });420}421422function handleClaudeEvent(chat, ev) {423 if (ev.type === 'system' && ev.subtype === 'init') {424 if (ev.session_id) chat.claudeSessionId = ev.session_id;425 if (ev.model) chat.lastModel = ev.model;426 saveChats();427 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });428 }429 if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {430 for (const block of ev.message.content) {431 if (block.type === 'tool_use') {432 audit({ kind: 'tool_use', chatId: chat.id, tool: block.name, input: JSON.stringify(block.input || {}).slice(0, 1000) });433 }434 }435 }436 if (ev.type === 'result') {437 chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0);438 chat.turns = (chat.turns || 0) + (ev.num_turns || 0);439 const u = ev.usage || {};440 chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);441 saveChats();442 audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns });443 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });444 }445 if (ev.type === 'stream_event') {446 broadcast(chat.id, { type: 'claude', event: ev });447 } else {448 emitAndLog(chat.id, { type: 'claude', event: ev });449 }450}451452function interrupt(chatId) {453 const st = running.get(chatId);454 if (!st) return;455 audit({ kind: 'interrupt', chatId });456 if (st.remote) {457 spawn('ssh', ['-o', 'BatchMode=yes', st.remote, `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], {458 env: { ...process.env, HOME }, stdio: 'ignore',459 });460 }461 st.proc.kill('SIGTERM');462 setTimeout(() => { try { st.proc.kill('SIGKILL'); } catch {} }, 5000);463}464465// exécuter un script sur un nœud (local ou ssh) — pour les actions d'admin466function runOnNode(node, script) {467 return new Promise((resolve) => {468 const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`;469 const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';470 const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full];471 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 } },472 (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null }));473 });474}475476// ---------- HTTP ----------477const 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' };478479function readBody(req, limit = 512 * 1024) {480 return new Promise((resolve, reject) => {481 let data = '';482 req.on('data', (c) => { data += c; if (data.length > limit) { reject(new Error('body trop gros')); req.destroy(); } });483 req.on('end', () => resolve(data));484 req.on('error', reject);485 });486}487function json(res, code, obj) {488 res.writeHead(code, { 'Content-Type': 'application/json' });489 res.end(JSON.stringify(obj));490}491492const server = http.createServer(async (req, res) => {493 const url = new URL(req.url, 'http://x');494 const p = url.pathname;495496 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }497498 // --- beacon analytics (public, sans auth) : v1 (pings 60 s) et v2 (pv/hb) ---499 if (p === '/collect') {500 try { recordHit(url.searchParams, req); } catch {}501 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' });502 return res.end(PIXEL);503 }504505 // logos des sites (favicon/apple-touch-icon en cache) — public, léger506 if (p.startsWith('/icons/')) {507 const icon = getIcon(p.split('/')[2]);508 if (!icon) { res.writeHead(404); return res.end(); }509 res.writeHead(200, { 'Content-Type': icon.type, 'Cache-Control': 'public, max-age=86400' });510 return res.end(icon.buf);511 }512513 // --- endpoint interne de permission (long-poll par perm-mcp.js) ---514 if (p === '/internal/perm' && req.method === 'POST') {515 let body;516 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }517 let chatId = null;518 for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid;519 if (!chatId) return json(res, 403, { error: 'bad token' });520 const chat = getChat(chatId);521 const toolKey = permKey(body.tool_name, body.input);522523 // « Tout autoriser » activé → toute la session est auto-approuvée : la tâche524 // va au bout sans jamais attendre l'utilisateur525 if (chat && chat.autoAllowAll) {526 audit({ kind: 'permission_auto_all', chatId, tool: body.tool_name });527 emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: 'tout autoriser', input: body.input });528 return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });529 }530531 // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation532 if (chat && (chat.alwaysAllow || []).includes(toolKey)) {533 audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey });534 emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input });535 return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });536 }537538 const requestId = crypto.randomBytes(8).toString('hex');539 const p2 = new Promise((resolve) => {540 const timer = setTimeout(() => {541 pendingPerms.delete(requestId);542 emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' });543 if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');544 resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" });545 }, 30 * 60 * 1000);546 pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey });547 });548 audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) });549 emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input });550 if (chat) setChatState(chat, 'waiting_perm');551 broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name });552 const decision = await p2;553 return json(res, 200, decision);554 }555556 // --- auth ---557 if (p === '/api/login' && req.method === 'POST') {558 const ip = clientIp(req);559 if (!loginAllowed(ip)) return json(res, 429, { error: 'Trop de tentatives — réessayer dans 15 minutes.' });560 let body;561 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }562 if (typeof body.password !== 'string' || !verifyPassword(body.password)) {563 loginFailed(ip);564 audit({ kind: 'login_fail', ip });565 return json(res, 401, { error: 'Mot de passe invalide.' });566 }567 const token = newSession();568 audit({ kind: 'login_ok', ip });569 res.writeHead(200, {570 'Content-Type': 'application/json',571 'Set-Cookie': `akid=${token}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax; Secure`,572 });573 return res.end(JSON.stringify({ ok: true }));574 }575 if (p === '/api/logout' && req.method === 'POST') {576 const t = getSession(req);577 if (t) { delete sessions[t]; saveSessions(); }578 res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'akid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax; Secure' });579 return res.end(JSON.stringify({ ok: true }));580 }581582 // --- API authentifiée ---583 if (p.startsWith('/api/')) {584 if (!getSession(req)) return json(res, 401, { error: 'auth' });585 if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '3.0.0' });586 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });587 if (p === '/api/chats' && req.method === 'GET') {588 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);589 return json(res, 200, { chats: list });590 }591 if (p.startsWith('/api/chats/') && req.method === 'DELETE') {592 const id = p.split('/')[3];593 const chat = getChat(id);594 if (chat && !running.has(id)) {595 chats = chats.filter(c => c.id !== id); saveChats();596 try { fs.unlinkSync(transcriptPath(id)); } catch {}597 return json(res, 200, { ok: true });598 }599 return json(res, 400, { error: 'introuvable ou en cours' });600 }601 // --- prompt bank ---602 if (p === '/api/prompts' && req.method === 'GET') {603 const list = [...prompts].sort((a, b) => (b.updated || 0) - (a.updated || 0));604 return json(res, 200, { prompts: list });605 }606 if (p === '/api/prompts' && req.method === 'POST') {607 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }608 const text = String(body.text || '').trim();609 if (!text) return json(res, 400, { error: 'texte vide' });610 const pr = {611 id: crypto.randomBytes(6).toString('hex'),612 title: (String(body.title || '').trim() || text.slice(0, 48)),613 text,614 project: findProject(body.project) ? body.project : null,615 fav: !!body.fav, uses: 0,616 created: Date.now(), updated: Date.now(),617 };618 prompts.push(pr); savePrompts();619 audit({ kind: 'prompt_save', id: pr.id });620 return json(res, 200, { prompt: pr });621 }622 if (p.startsWith('/api/prompts/') && req.method === 'PUT') {623 const id = p.split('/')[3];624 const pr = prompts.find(x => x.id === id);625 if (!pr) return json(res, 404, { error: 'introuvable' });626 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }627 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();628 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);629 if ('project' in body) pr.project = findProject(body.project) ? body.project : null;630 if (typeof body.fav === 'boolean') pr.fav = body.fav;631 if (body.bumpUse) pr.uses = (pr.uses || 0) + 1;632 pr.updated = Date.now(); savePrompts();633 return json(res, 200, { prompt: pr });634 }635 if (p.startsWith('/api/prompts/') && req.method === 'DELETE') {636 const id = p.split('/')[3];637 prompts = prompts.filter(x => x.id !== id); savePrompts();638 return json(res, 200, { ok: true });639 }640 if (p === '/api/upgrade-prompt' && req.method === 'POST') {641 let body;642 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }643 const raw = String(body.prompt || '').trim();644 if (!raw) return json(res, 400, { error: 'prompt vide' });645 try {646 const project = findProject(body.project);647 const upgraded = await upgradePrompt(raw, project);648 audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length });649 return json(res, 200, { upgraded });650 } catch (e) {651 return json(res, 502, { error: e.message });652 }653 }654 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES));655 if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30));656 if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7));657 if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(SITES));658 if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies());659 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') }));660 if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5));661 if (p === '/api/analytics/config' && req.method === 'GET') return json(res, 200, analyticsConfig());662 if (p === '/api/analytics/config' && req.method === 'PUT') {663 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }664 audit({ kind: 'analytics_config', ip: clientIp(req) });665 return json(res, 200, setAnalyticsConfig(body));666 }667 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());668 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });669 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());670 if (p.startsWith('/api/eco/site/')) {671 const d = ecoSite(p.split('/')[4]);672 return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' });673 }674675 // --- actions d'administration par site (pm2 / logs / commits) ---676 if (p === '/api/admin/action' && req.method === 'POST') {677 let body;678 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }679 const site = SITES.find(s => s.app === body.app);680 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;681 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });682 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });683 let out;684 if (site.unit) {685 // sites sous launchd (KA Guardian sur M4M36) — pas de pm2 sur ce nœud686 const cmds = {687 restart: `launchctl kickstart -k gui/$(id -u)/${site.unit}`,688 stop: `launchctl unload ~/Library/LaunchAgents/${site.unit}.plist`,689 start: `launchctl load ~/Library/LaunchAgents/${site.unit}.plist`,690 };691 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é"}')`);692 } else {693 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')"`);694 }695 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });696 }697 if (p.startsWith('/api/admin/logs/')) {698 const site = SITES.find(s => s.app === p.split('/')[4]);699 if (!site) return json(res, 404, { error: 'site inconnu' });700 const out = site.log701 ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`)702 : await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`);703 return json(res, 200, { logs: out.stdout || out.error || '(vide)' });704 }705 if (p.startsWith('/api/admin/commits/')) {706 const site = SITES.find(s => s.app === p.split('/')[4]);707 if (!site) return json(res, 404, { error: 'site inconnu' });708 const out = await runOnNode(site.node, `cd ${site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`);709 return json(res, 200, { text: out.stdout || out.error || '(vide)' });710 }711 // --- Social : publication automatique + a la demande ---712 if (p === '/api/social/vncpw' && req.method === 'GET') return json(res, 200, { password: config.vncPassword || '492592' });713 if (p === '/api/social/state' && req.method === 'GET') {714 return json(res, 200, { state: socialState(), log: socialLog(40) });715 }716 if (p === '/api/social/auto' && req.method === 'POST') {717 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }718 audit({ kind: 'social_auto', on: !!body.on, ip: clientIp(req) });719 return json(res, 200, { state: setAuto(!!body.on) });720 }721 if (p === '/api/social/login' && req.method === 'GET') {722 try { return json(res, 200, { login: await fbLoginState() }); }723 catch (e) { return json(res, 502, { error: e.message }); }724 }725 if (p === '/api/social/generate' && req.method === 'POST') {726 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }727 try {728 const draft = await generateDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() });729 audit({ kind: 'social_generate', site: draft.insight.site });730 return json(res, 200, { draft });731 } catch (e) { return json(res, 502, { error: e.message }); }732 }733 if (p === '/api/social/publish' && req.method === 'POST') {734 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }735 try {736 await publishDraft({ caption: String(body.caption || ''), image: cardPath(body.image || '') });737 audit({ kind: 'social_publish', ip: clientIp(req) });738 return json(res, 200, { ok: true });739 } catch (e) { return json(res, 502, { error: e.message }); }740 }741 if (p === '/api/social/run' && req.method === 'POST') {742 audit({ kind: 'social_run_now', ip: clientIp(req) });743 const r = await runAutoCycle('manuel');744 return json(res, r.ok ? 200 : 502, r);745 }746 if (p.startsWith('/api/social/card/') && req.method === 'GET') {747 const fp = cardPath(p.split('/')[4] || '');748 if (!fp) { res.writeHead(404); return res.end(); }749 res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });750 return res.end(fs.readFileSync(fp));751 }752 if (p === '/api/social/reel/generate' && req.method === 'POST') {753 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }754 try {755 const draft = await generateReelDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() });756 audit({ kind: 'social_reel_generate', site: draft.insight.site });757 return json(res, 200, { draft });758 } catch (e) { return json(res, 502, { error: e.message }); }759 }760 if (p === '/api/social/reel/publish' && req.method === 'POST') {761 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }762 try {763 await publishReelDraft({ caption: String(body.caption || ''), video: reelPath(body.video || '') });764 audit({ kind: 'social_reel_publish', ip: clientIp(req) });765 return json(res, 200, { ok: true });766 } catch (e) { return json(res, 502, { error: e.message }); }767 }768 if (p === '/api/social/reel/run' && req.method === 'POST') {769 audit({ kind: 'social_reel_run', ip: clientIp(req) });770 const r = await runReelCycle('reel-manuel');771 return json(res, r.ok ? 200 : 502, r);772 }773 if (p === '/api/social/gallery' && req.method === 'GET') return json(res, 200, { items: getGallery(), gen: genBatchStatus() });774 if (p === '/api/social/reel/genbatch' && req.method === 'POST') {775 let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; }776 const sites = (Array.isArray(body.sites) && body.sites.length) ? body.sites : REEL_BATCH_DEFAULT;777 audit({ kind: 'social_genbatch', n: sites.length, ip: clientIp(req) });778 const r = startGenBatch(sites);779 return json(res, r.started ? 200 : 409, r);780 }781 if (p === '/api/social/reel/batch' && req.method === 'POST') {782 let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; }783 const sites = (Array.isArray(body.sites) && body.sites.length) ? body.sites : REEL_BATCH_DEFAULT;784 audit({ kind: 'social_reel_batch', n: sites.length, ip: clientIp(req) });785 const r = startReelBatch(sites);786 return json(res, r.started ? 200 : 409, r);787 }788 if (p === '/api/social/reel/batchstatus' && req.method === 'GET') return json(res, 200, batchStatus());789 if (p.startsWith('/api/social/reel/') && req.method === 'GET') {790 const fp = reelPath(p.split('/')[4] || '');791 if (!fp) { res.writeHead(404); return res.end(); }792 const stat = fs.statSync(fp);793 res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': stat.size, 'Cache-Control': 'no-cache' });794 return res.end(fs.readFileSync(fp));795 }796797 return json(res, 404, { error: 'not found' });798 }799800 // --- statique ---801 let file = p === '/' ? '/index.html' : p;802 file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');803 const full = path.join(PUBLIC_DIR, file);804 if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end(); }805 fs.readFile(full, (err, data) => {806 if (err) { res.writeHead(404); return res.end('not found'); }807 res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream', 'Cache-Control': full.includes('/vendor/') ? 'public, max-age=86400' : 'no-cache' });808 res.end(data);809 });810});811812// ---------- WebSocket ----------813const wss = new WebSocketServer({ noServer: true });814815// --- pont VNC : WebSocket (noVNC dans l'admin) <-> Partage d'écran du nœud (127.0.0.1:5900) ---816const vncWss = new WebSocketServer({ noServer: true });817vncWss.on('connection', (ws) => {818 const tcp = net.connect(5900, '127.0.0.1');819 tcp.on('data', (d) => { if (ws.readyState === 1) ws.send(d); });820 tcp.on('close', () => { try { ws.close(); } catch {} });821 tcp.on('error', () => { try { ws.close(); } catch {} });822 ws.on('message', (d) => { try { tcp.write(d); } catch {} });823 ws.on('close', () => { try { tcp.end(); } catch {} });824 ws.on('error', () => { try { tcp.destroy(); } catch {} });825});826827server.on('upgrade', (req, socket, head) => {828 const url = new URL(req.url, 'http://x');829 if (!getSession(req)) {830 socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');831 return socket.destroy();832 }833 if (url.pathname === '/ws') {834 wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));835 } else if (url.pathname === '/vncws') {836 vncWss.handleUpgrade(req, socket, head, (ws) => vncWss.emit('connection', ws, req));837 } else {838 socket.write('HTTP/1.1 404 Not Found\r\n\r\n');839 socket.destroy();840 }841});842843wss.on('connection', (ws) => {844 ws.chatId = null;845 ws.eco = false;846 ws.isAlive = true;847 ws.on('pong', () => { ws.isAlive = true; });848 ws.on('message', (raw) => {849 let msg;850 try { msg = JSON.parse(raw.toString()); } catch { return; }851 try { handleWsMessage(ws, msg); } catch (e) {852 ws.send(JSON.stringify({ type: 'error', message: e.message }));853 }854 });855 // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours.856});857858setInterval(() => {859 for (const ws of wss.clients) {860 if (!ws.isAlive) { ws.terminate(); continue; }861 ws.isAlive = false; ws.ping();862 }863}, 30000);864865function handleWsMessage(ws, msg) {866 if (msg.type === 'open') {867 const chat = getChat(msg.chatId);868 if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' }));869 ws.chatId = chat.id;870 // rejoue tout l'historique persisté puis raccroche le live871 ws.send(JSON.stringify({872 type: 'history',873 chat: publicChat(chat),874 events: readTranscript(chat.id),875 running: running.has(chat.id),876 }));877 return;878 }879 if (msg.type === 'start') {880 const prompt = String(msg.prompt || '').trim();881 if (!prompt) return;882 let chat = msg.chatId ? getChat(msg.chatId) : null;883 if (!chat) {884 const proj = findProject(msg.project) || PROJECTS[0];885 chat = {886 id: crypto.randomBytes(8).toString('hex'),887 title: prompt.slice(0, 60),888 projectId: proj.id, projectName: proj.name,889 node: proj.node, dir: proj.dir,890 model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL,891 permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default',892 claudeSessionId: null, state: 'idle',893 totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [],894 created: Date.now(), updated: Date.now(),895 };896 chats.push(chat); saveChats();897 ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) }));898 } else {899 if (MODELS.includes(msg.model)) chat.model = msg.model;900 if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;901 saveChats();902 }903 ws.chatId = chat.id;904 emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() });905 startClaude(chat, prompt);906 return;907 }908 if (msg.type === 'start_all') {909 // diffuse le MÊME prompt à TOUTES les apps KA en parallèle (1 session/app)910 const prompt = String(msg.prompt || '').trim();911 if (!prompt) return;912 const model = MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL;913 const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default';914 const created = [];915 const stamp = new Date().toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });916 for (const proj of PROJECTS) {917 const chat = {918 id: crypto.randomBytes(8).toString('hex'),919 title: '🌐 ' + prompt.slice(0, 46),920 projectId: proj.id, projectName: proj.name,921 node: proj.node, dir: proj.dir,922 model, permMode,923 claudeSessionId: null, state: 'idle',924 totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [],925 batch: stamp,926 created: Date.now(), updated: Date.now(),927 };928 chats.push(chat);929 emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() });930 try { startClaude(chat, prompt); created.push(chat.id); }931 catch (e) { emitAndLog(chat.id, { type: 'error', message: e.message }); }932 }933 saveChats();934 audit({ kind: 'start_all', count: created.length, model, permMode, prompt: prompt.slice(0, 500) });935 ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: PROJECTS.length }));936 return;937 }938 if (msg.type === 'set_opts') {939 const chat = getChat(msg.chatId);940 if (!chat) return;941 if (MODELS.includes(msg.model)) chat.model = msg.model;942 if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;943 if (typeof msg.autoAllowAll === 'boolean') chat.autoAllowAll = msg.autoAllowAll;944 saveChats();945 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });946 return;947 }948 if (msg.type === 'clear') {949 const chat = getChat(msg.chatId);950 if (!chat || running.has(chat.id)) return;951 chat.claudeSessionId = null;952 chat.contextTokens = 0;953 saveChats();954 emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' });955 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });956 return;957 }958 if (msg.type === 'perm') {959 const pending = pendingPerms.get(msg.requestId);960 if (!pending) return;961 clearTimeout(pending.timer);962 pendingPerms.delete(msg.requestId);963 const chat = getChat(pending.chatId);964 if (msg.allow && msg.allowAll && chat) {965 // « Tout autoriser » : plus AUCUNE permission ne sera demandée pour cette966 // conversation — le serveur approuve tout jusqu'à la fin de la tâche967 chat.autoAllowAll = true;968 saveChats();969 emitAndLog(chat.id, { type: 'notice', text: '🔓 Tout autoriser activé — la tâche ira au bout sans autre demande de permission.' });970 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });971 }972 if (msg.allow && msg.always && chat) {973 if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey);974 saveChats();975 }976 audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });977 emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });978 if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');979 pending.resolve(msg.allow980 ? { behavior: 'allow', updatedInput: pending.input || {} }981 : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" });982 return;983 }984 if (msg.type === 'interrupt') {985 if (msg.chatId) interrupt(msg.chatId);986 else if (ws.chatId) interrupt(ws.chatId);987 return;988 }989 if (msg.type === 'eco_sub') { ws.eco = true; return; }990 if (msg.type === 'eco_unsub') { ws.eco = false; return; }991}992993// ---------- monitoring Écosystème ----------994startMonitor({995 dataDir: DATA_DIR,996 onEvent: (ev) => {997 if (ev.type === 'eco_alert') broadcastAll(ev); // alertes → tout le monde998 else broadcastEco(ev); // ticks → abonnés à l'onglet999 },1000});10011002// module Social : publication automatique horaire + génération sur demande1003initSocial(1004 { anthropicKey: UPGRADER_KEY, upgraderModel: UPGRADER_MODEL,1005 socialModel: config.socialModel, socialIntervalMin: config.socialIntervalMin, reelEvery: config.reelEvery },1006 (line) => broadcastAll({ type: 'social_log', line }),1007);10081009initAnalytics();10101011// au démarrage, les chats marqués "running" par un ancien processus sont orphelins1012for (const c of chats) if (c.state !== 'idle') c.state = 'idle';1013saveChats();10141015server.listen(PORT, '::', () => {1016 console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`);1017});1018