SPB Git

spb/admin-ka

Public
26.5 KB · 635 lines javascript
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 crypto from 'node:crypto';6import fs from 'node:fs';7import path from 'node:path';8import os from 'node:os';9import { spawn } from 'node:child_process';10import { fileURLToPath } from 'node:url';11import { WebSocketServer } from 'ws';12import { startMonitor, ecoSummary, ecoSite, ecoIncidents } from './monitor.js';1314const __dirname = path.dirname(fileURLToPath(import.meta.url));15const APP_DIR = path.join(__dirname, '..');16const DATA_DIR = path.join(APP_DIR, 'data');17const TRANSCRIPTS_DIR = path.join(DATA_DIR, 'transcripts');18const PUBLIC_DIR = path.join(APP_DIR, 'public');19const HOME = os.homedir();2021fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });2223// ---------- config ----------24const CONFIG_PATH = path.join(DATA_DIR, 'config.json');25if (!fs.existsSync(CONFIG_PATH)) {26  console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>');27  process.exit(1);28}29const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));30const PORT = config.port || 3300;31const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude';32const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node';3334function loadAnthropicKey() {35  try {36    const envFile = fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8');37    const m = envFile.match(/ANTHROPIC_API_KEY=(\S+)/);38    if (m) return m[1];39  } catch {}40  return process.env.ANTHROPIC_API_KEY || '';41}42const ANTHROPIC_API_KEY = loadAnthropicKey();4344// ---------- audit ----------45const AUDIT_PATH = path.join(DATA_DIR, 'audit.jsonl');46function audit(entry) {47  fs.appendFile(AUDIT_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', () => {});48}4950// ---------- sessions (auth) ----------51const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json');52let sessions = {};53try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {}54function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); }55const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000;56const SESSION_MAX_MS = 30 * 24 * 3600 * 1000;5758function verifyPassword(pw) {59  const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex');60  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(config.passwordHash));61}62function newSession() {63  const token = crypto.randomBytes(32).toString('hex');64  sessions[token] = { created: Date.now(), last: Date.now() };65  saveSessions();66  return token;67}68function getSession(req) {69  const cookie = req.headers.cookie || '';70  const m = cookie.match(/(?:^|;\s*)akid=([a-f0-9]{64})/);71  if (!m) return null;72  const s = sessions[m[1]];73  if (!s) return null;74  const now = Date.now();75  if (now - s.last > SESSION_IDLE_MS || now - s.created > SESSION_MAX_MS) {76    delete sessions[m[1]]; saveSessions(); return null;77  }78  s.last = now;79  return m[1];80}8182const loginFails = new Map();83function clientIp(req) {84  const xf = req.headers['x-forwarded-for'];85  return (xf ? String(xf).split(',')[0].trim() : req.socket.remoteAddress) || '?';86}87function loginAllowed(ip) {88  const rec = loginFails.get(ip);89  if (!rec) return true;90  if (Date.now() - rec.first > 15 * 60 * 1000) { loginFails.delete(ip); return true; }91  return rec.count < 8;92}93function loginFailed(ip) {94  const rec = loginFails.get(ip) || { first: Date.now(), count: 0 };95  rec.count++; loginFails.set(ip, rec);96}9798// ---------- chats ----------99const CHATS_PATH = path.join(DATA_DIR, 'chats.json');100let chats = [];101try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {}102// migration v1 → v2103for (const c of chats) {104  if (!c.model) c.model = 'fable';105  if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default';106  if (!c.state) c.state = 'idle';107  if (c.totalCost === undefined) c.totalCost = 0;108  if (!c.alwaysAllow) c.alwaysAllow = [];109}110function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); }111function getChat(id) { return chats.find(c => c.id === id); }112function publicChat(c) {113  return {114    id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName,115    model: c.model, permMode: c.permMode, state: c.state,116    claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null,117    totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0,118    created: c.created, updated: c.updated,119  };120}121function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); }122function appendTranscript(chatId, event) {123  fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {});124}125function readTranscript(chatId) {126  try {127    return fs.readFileSync(transcriptPath(chatId), 'utf8')128      .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } })129      .filter(Boolean);130  } catch { return []; }131}132133// ---------- projets (12 apps Groupe KA) ----------134const SELF_NODE = 'M3U96a';135const PROJECTS = [136  { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' },137  { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' },138  { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' },139  { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },140  { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },141  { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },142  { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },143  { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },144  { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },145  { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },146  { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },147  { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },148  { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },149];150function listProjects() {151  return PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir }));152}153function findProject(id) { return PROJECTS.find(p => p.id === id) || null; }154155// ---------- exécution Claude Code ----------156const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];157// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions)158const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };159const DEFAULT_MODEL = 'fable';160const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass'];161const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task';162163const 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.`;164165const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;166167// chatId -> { proc, permToken, stderr, remote }168const running = new Map();169// requestId -> { chatId, resolve, timer, input, toolKey }170const pendingPerms = new Map();171172function broadcast(chatId, msg) {173  const data = JSON.stringify(msg);174  for (const ws of wss.clients) {175    if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data);176  }177}178function broadcastAll(msg) {179  const data = JSON.stringify(msg);180  for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data);181}182function broadcastEco(msg) {183  const data = JSON.stringify(msg);184  for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data);185}186function emitAndLog(chatId, msg) {187  appendTranscript(chatId, msg);188  broadcast(chatId, msg);189}190function setChatState(chat, state) {191  chat.state = state;192  chat.updated = Date.now();193  saveChats();194  broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });195}196197function permKey(tool, input) {198  if (tool === 'Bash') {199    const first = String(input?.command || '').trim().split(/\s+/)[0] || '?';200    return 'Bash:' + first;201  }202  return tool;203}204205function startClaude(chat, prompt) {206  if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.');207  const permToken = crypto.randomBytes(16).toString('hex');208  const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL;209  const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';210  let proc;211212  const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',213    '--model', CLI_MODEL[model], '--append-system-prompt', SYSTEM_APPEND];214  if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);215216  if (!chat.node) {217    // exécution locale (app déployée sur M3U96a)218    const args = [...baseArgs];219    if (permMode === 'bypass') {220      args.push('--dangerously-skip-permissions');221    } else {222      args.push('--permission-mode', permMode);223      const mcpCfg = {224        mcpServers: {225          adminka: {226            command: NODE_BIN,227            args: [path.join(__dirname, 'perm-mcp.js')],228            env: {229              ADMIN_KA_PERM_URL: `http://127.0.0.1:${PORT}/internal/perm`,230              ADMIN_KA_PERM_TOKEN: permToken,231            },232          },233        },234      };235      const cfgPath = path.join(DATA_DIR, `mcp-${chat.id}.json`);236      fs.writeFileSync(cfgPath, JSON.stringify(mcpCfg));237      args.push('--mcp-config', cfgPath);238      args.push('--permission-prompt-tool', 'mcp__adminka__approve');239      args.push('--allowedTools', ALLOWED_READONLY);240    }241    proc = spawn(CLAUDE_BIN, args, {242      cwd: chat.dir,243      env: {244        ...process.env, ANTHROPIC_API_KEY,245        PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME,246      },247      stdio: ['pipe', 'pipe', 'pipe'],248      detached: false,249    });250  } else {251    // exécution distante : claude tourne SUR le nœud de déploiement de l'app,252    // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée).253    const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6'];254    let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' ');255    if (permMode === 'bypass') {256      cmd += ' --dangerously-skip-permissions';257    } else {258      const revPort = 21000 + Math.floor(Math.random() * 3000);259      sshArgs.push('-R', `127.0.0.1:${revPort}:127.0.0.1:${PORT}`);260      const mcpCfg = {261        mcpServers: {262          adminka: {263            command: '/opt/homebrew/bin/node',264            args: ['/Users/simon-pierreboucher/.adminka/perm-mcp.js'],265            env: {266              ADMIN_KA_PERM_URL: `http://127.0.0.1:${revPort}/internal/perm`,267              ADMIN_KA_PERM_TOKEN: permToken,268            },269          },270        },271      };272      const cfgFile = `/tmp/adminka-mcp-${permToken}.json`;273      cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`;274    }275    const remoteCmd = [276      `cd ${chat.dir}`,277      'export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH',278      'export $(cat ~/.claude/.env | xargs)',279      `echo $$ > /tmp/adminka-pid-${permToken}`,280      cmd,281    ].join(' && ');282    proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], {283      env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },284      stdio: ['pipe', 'pipe', 'pipe'],285    });286  }287  proc.stdin.write(prompt);288  proc.stdin.end();289290  const state = { proc, permToken, stderr: '', remote: chat.node || null };291  running.set(chat.id, state);292  audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });293  emitAndLog(chat.id, { type: 'status', state: 'running' });294  setChatState(chat, 'running');295296  let buf = '';297  proc.stdout.on('data', (d) => {298    buf += d.toString('utf8');299    let idx;300    while ((idx = buf.indexOf('\n')) >= 0) {301      const line = buf.slice(0, idx).trim();302      buf = buf.slice(idx + 1);303      if (!line) continue;304      let ev;305      try { ev = JSON.parse(line); } catch { continue; }306      handleClaudeEvent(chat, ev);307    }308  });309  proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); });310  proc.on('close', (code) => {311    running.delete(chat.id);312    for (const [rid, p] of pendingPerms) {313      if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); }314    }315    if (code !== 0 && code !== null) {316      emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` });317    }318    emitAndLog(chat.id, { type: 'status', state: 'idle' });319    setChatState(chat, 'idle');320    broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName });321  });322  proc.on('error', (err) => {323    running.delete(chat.id);324    emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message });325    emitAndLog(chat.id, { type: 'status', state: 'idle' });326    setChatState(chat, 'idle');327  });328}329330function handleClaudeEvent(chat, ev) {331  if (ev.type === 'system' && ev.subtype === 'init') {332    if (ev.session_id) chat.claudeSessionId = ev.session_id;333    if (ev.model) chat.lastModel = ev.model;334    saveChats();335    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });336  }337  if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {338    for (const block of ev.message.content) {339      if (block.type === 'tool_use') {340        audit({ kind: 'tool_use', chatId: chat.id, tool: block.name, input: JSON.stringify(block.input || {}).slice(0, 1000) });341      }342    }343  }344  if (ev.type === 'result') {345    chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0);346    chat.turns = (chat.turns || 0) + (ev.num_turns || 0);347    const u = ev.usage || {};348    chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);349    saveChats();350    audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns });351    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });352  }353  if (ev.type === 'stream_event') {354    broadcast(chat.id, { type: 'claude', event: ev });355  } else {356    emitAndLog(chat.id, { type: 'claude', event: ev });357  }358}359360function interrupt(chatId) {361  const st = running.get(chatId);362  if (!st) return;363  audit({ kind: 'interrupt', chatId });364  if (st.remote) {365    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}`], {366      env: { ...process.env, HOME }, stdio: 'ignore',367    });368  }369  st.proc.kill('SIGTERM');370  setTimeout(() => { try { st.proc.kill('SIGKILL'); } catch {} }, 5000);371}372373// ---------- HTTP ----------374const 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' };375376function readBody(req, limit = 512 * 1024) {377  return new Promise((resolve, reject) => {378    let data = '';379    req.on('data', (c) => { data += c; if (data.length > limit) { reject(new Error('body trop gros')); req.destroy(); } });380    req.on('end', () => resolve(data));381    req.on('error', reject);382  });383}384function json(res, code, obj) {385  res.writeHead(code, { 'Content-Type': 'application/json' });386  res.end(JSON.stringify(obj));387}388389const server = http.createServer(async (req, res) => {390  const url = new URL(req.url, 'http://x');391  const p = url.pathname;392393  if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }394395  // --- endpoint interne de permission (long-poll par perm-mcp.js) ---396  if (p === '/internal/perm' && req.method === 'POST') {397    let body;398    try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }399    let chatId = null;400    for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid;401    if (!chatId) return json(res, 403, { error: 'bad token' });402    const chat = getChat(chatId);403    const toolKey = permKey(body.tool_name, body.input);404405    // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation406    if (chat && (chat.alwaysAllow || []).includes(toolKey)) {407      audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey });408      emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input });409      return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });410    }411412    const requestId = crypto.randomBytes(8).toString('hex');413    const p2 = new Promise((resolve) => {414      const timer = setTimeout(() => {415        pendingPerms.delete(requestId);416        emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' });417        if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');418        resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" });419      }, 30 * 60 * 1000);420      pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey });421    });422    audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) });423    emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input });424    if (chat) setChatState(chat, 'waiting_perm');425    broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name });426    const decision = await p2;427    return json(res, 200, decision);428  }429430  // --- auth ---431  if (p === '/api/login' && req.method === 'POST') {432    const ip = clientIp(req);433    if (!loginAllowed(ip)) return json(res, 429, { error: 'Trop de tentatives — réessayer dans 15 minutes.' });434    let body;435    try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }436    if (typeof body.password !== 'string' || !verifyPassword(body.password)) {437      loginFailed(ip);438      audit({ kind: 'login_fail', ip });439      return json(res, 401, { error: 'Mot de passe invalide.' });440    }441    const token = newSession();442    audit({ kind: 'login_ok', ip });443    res.writeHead(200, {444      'Content-Type': 'application/json',445      'Set-Cookie': `akid=${token}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax; Secure`,446    });447    return res.end(JSON.stringify({ ok: true }));448  }449  if (p === '/api/logout' && req.method === 'POST') {450    const t = getSession(req);451    if (t) { delete sessions[t]; saveSessions(); }452    res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'akid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax; Secure' });453    return res.end(JSON.stringify({ ok: true }));454  }455456  // --- API authentifiée ---457  if (p.startsWith('/api/')) {458    if (!getSession(req)) return json(res, 401, { error: 'auth' });459    if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' });460    if (p === '/api/projects') return json(res, 200, { projects: listProjects() });461    if (p === '/api/chats' && req.method === 'GET') {462      const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);463      return json(res, 200, { chats: list });464    }465    if (p.startsWith('/api/chats/') && req.method === 'DELETE') {466      const id = p.split('/')[3];467      const chat = getChat(id);468      if (chat && !running.has(id)) {469        chats = chats.filter(c => c.id !== id); saveChats();470        try { fs.unlinkSync(transcriptPath(id)); } catch {}471        return json(res, 200, { ok: true });472      }473      return json(res, 400, { error: 'introuvable ou en cours' });474    }475    if (p === '/api/eco/summary') return json(res, 200, ecoSummary());476    if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });477    if (p.startsWith('/api/eco/site/')) {478      const d = ecoSite(p.split('/')[4]);479      return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' });480    }481    return json(res, 404, { error: 'not found' });482  }483484  // --- statique ---485  let file = p === '/' ? '/index.html' : p;486  file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');487  const full = path.join(PUBLIC_DIR, file);488  if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end(); }489  fs.readFile(full, (err, data) => {490    if (err) { res.writeHead(404); return res.end('not found'); }491    res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream', 'Cache-Control': full.includes('/vendor/') ? 'public, max-age=86400' : 'no-cache' });492    res.end(data);493  });494});495496// ---------- WebSocket ----------497const wss = new WebSocketServer({ noServer: true });498499server.on('upgrade', (req, socket, head) => {500  const url = new URL(req.url, 'http://x');501  if (url.pathname !== '/ws' || !getSession(req)) {502    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');503    return socket.destroy();504  }505  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));506});507508wss.on('connection', (ws) => {509  ws.chatId = null;510  ws.eco = false;511  ws.isAlive = true;512  ws.on('pong', () => { ws.isAlive = true; });513  ws.on('message', (raw) => {514    let msg;515    try { msg = JSON.parse(raw.toString()); } catch { return; }516    try { handleWsMessage(ws, msg); } catch (e) {517      ws.send(JSON.stringify({ type: 'error', message: e.message }));518    }519  });520  // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours.521});522523setInterval(() => {524  for (const ws of wss.clients) {525    if (!ws.isAlive) { ws.terminate(); continue; }526    ws.isAlive = false; ws.ping();527  }528}, 30000);529530function handleWsMessage(ws, msg) {531  if (msg.type === 'open') {532    const chat = getChat(msg.chatId);533    if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' }));534    ws.chatId = chat.id;535    // rejoue tout l'historique persisté puis raccroche le live536    ws.send(JSON.stringify({537      type: 'history',538      chat: publicChat(chat),539      events: readTranscript(chat.id),540      running: running.has(chat.id),541    }));542    return;543  }544  if (msg.type === 'start') {545    const prompt = String(msg.prompt || '').trim();546    if (!prompt) return;547    let chat = msg.chatId ? getChat(msg.chatId) : null;548    if (!chat) {549      const proj = findProject(msg.project) || PROJECTS[0];550      chat = {551        id: crypto.randomBytes(8).toString('hex'),552        title: prompt.slice(0, 60),553        projectId: proj.id, projectName: proj.name,554        node: proj.node, dir: proj.dir,555        model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL,556        permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default',557        claudeSessionId: null, state: 'idle',558        totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [],559        created: Date.now(), updated: Date.now(),560      };561      chats.push(chat); saveChats();562      ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) }));563    } else {564      if (MODELS.includes(msg.model)) chat.model = msg.model;565      if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;566      saveChats();567    }568    ws.chatId = chat.id;569    emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() });570    startClaude(chat, prompt);571    return;572  }573  if (msg.type === 'set_opts') {574    const chat = getChat(msg.chatId);575    if (!chat) return;576    if (MODELS.includes(msg.model)) chat.model = msg.model;577    if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;578    saveChats();579    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });580    return;581  }582  if (msg.type === 'clear') {583    const chat = getChat(msg.chatId);584    if (!chat || running.has(chat.id)) return;585    chat.claudeSessionId = null;586    chat.contextTokens = 0;587    saveChats();588    emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' });589    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });590    return;591  }592  if (msg.type === 'perm') {593    const pending = pendingPerms.get(msg.requestId);594    if (!pending) return;595    clearTimeout(pending.timer);596    pendingPerms.delete(msg.requestId);597    const chat = getChat(pending.chatId);598    if (msg.allow && msg.always && chat) {599      if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey);600      saveChats();601    }602    audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });603    emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });604    if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');605    pending.resolve(msg.allow606      ? { behavior: 'allow', updatedInput: pending.input || {} }607      : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" });608    return;609  }610  if (msg.type === 'interrupt') {611    if (msg.chatId) interrupt(msg.chatId);612    else if (ws.chatId) interrupt(ws.chatId);613    return;614  }615  if (msg.type === 'eco_sub') { ws.eco = true; return; }616  if (msg.type === 'eco_unsub') { ws.eco = false; return; }617}618619// ---------- monitoring Écosystème ----------620startMonitor({621  dataDir: DATA_DIR,622  onEvent: (ev) => {623    if (ev.type === 'eco_alert') broadcastAll(ev);   // alertes → tout le monde624    else broadcastEco(ev);                            // ticks → abonnés à l'onglet625  },626});627628// au démarrage, les chats marqués "running" par un ancien processus sont orphelins629for (const c of chats) if (c.state !== 'idle') c.state = 'idle';630saveChats();631632server.listen(PORT, '::', () => {633  console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`);634});635
Files · bde8b3aebfee1ae085dc6f4ccfd05e1e8a6d7356
Menu

Theme