SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

topologie vivante : sites, projets Claude Code et nœuds lus dans le registre mld (plus rien en dur)

- server/registry.js : registre M1M32:~/dispatch/registry.json (copie data/registry.json
  poussée par mld + surveillée, tirage ssh 2 min), SITES/getSites, getProjects/findProject
  (ids app@nœud résolus par app), selfNode (entrée admin-ka du registre), sshTarget
  (alias ssh sinon user@IP LAN), topologyText pour les prompts, CLAUDE.md de
  l orchestrateur régénéré, outillage automatique des nœuds (perm-mcp, CLAUDE.md contextuel)
- monitor.js / server.js : plus de SITES/PROJECTS/NODES/SELF_NODE figés ; realignChat
  suit une app déménagée ; API /api/registry (+refresh, +tooling) ; alertes registre
- UI : carte Registre dans Réglages, sélecteur d apps re-rempli à chaud, alerte déménagement
- KA_CATALOG = connaissance Ka seulement (libellés, launchd gardiens, pm2 non déductibles)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 19 days ago (Sep 5, 2026) parent 0809871

7 changed files +591 −89

modified .gitignore +1 −0
@@ -9,3 +9,4 @@ data/chats.json
9 9 data/sessions.json
10 10 data/mcp-*.json
11 11 data/transcripts/
12 +data/registry.json
modified CLAUDE.md +4 −3
@@ -22,9 +22,10 @@ iPhone ⇄ wss://www.administration-ka.com (ngrok) ⇄ backend :3300 (M3U96a)
22 22 backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS
23 23 ```
24 24
25 +- `server/registry.js` — **topologie vivante** (2026-09-04) : où tourne chaque app Ka = registre mld de la passerelle (`M1M32:~/dispatch/registry.json`). Copie `data/registry.json` poussée par `mld` (abonné `admin-ka`) et surveillée (fs.watchFile), + tirage ssh toutes les 2 min (`M1M32` puis `gitsrv`). Fournit `SITES`/`getSites()` (monitoring), `getProjects()`/`findProject()` (projets Claude Code, ids `app@nœud` résolus par app), `selfNode()` (nœud de la console = entrée `admin-ka` du registre), `sshTarget()` (alias ssh sinon `user@IP LAN`), `topologyText()` (bloc injecté dans les prompts système), régénère `~/ka-orchestrator/CLAUDE.md` à chaque changement et outille les nœuds hébergeurs (`~/.adminka/perm-mcp.js`, `~/.claude/CLAUDE.md` contextuel). `KA_CATALOG` ne contient que la connaissance Ka (libellés, launchd des gardiens, pm2 non déductibles) — **plus jamais d'emplacement en dur**. API : `GET /api/registry`, `POST /api/registry/refresh`, `POST /api/registry/tooling` ; UI : carte « Registre » dans Réglages, alerte `eco_alert kind=registry` + message WS `projects` quand une app bouge ; une conversation dont l'app a déménagé est réalignée à son prochain tour (`realignChat`, nouvelle session Claude).
25 26 - `server/server.js` — HTTP + WS + orchestration claude + états de session.
26 - `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native).
27 - `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`.
27 +- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native) ; sites et nœuds = `registry.js`.
28 +- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; copié automatiquement dans `~/.adminka/` de chaque nœud hébergeur connu du registre (`ensureNodeTooling`, contrôle toutes les 6 h ou bouton « Vérifier l'outillage » dans Réglages).
28 29 - `public/` — frontend une page, 4 onglets, aucun framework.
29 30
30 31 ## Pièges connus (ne pas re-découvrir)
@@ -46,7 +47,7 @@ tail -f ~/apps/admin-ka/data/backend.err.log
46 47 node server/set-password.js '<nouveau>' # changer le mot de passe
47 48 ```
48 49
49 Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`.
50 +Registre des déploiements : **`M1M32:~/dispatch/registry.json`** (orchestrateur `mld`, source de vérité — `cluster-deployments.json` du laptop est l'ancien registre, ne plus s'en servir). Repo : spbgit `gitsrv:srv/git/admin-ka.git` (bare sur M1M32).
50 51
51 52 ## v3 (2026-08-26) — Control Center : refonte UI + analytics humains
52 53
modified public/app.js +69 −11
@@ -1022,7 +1022,18 @@
1022 1022 }
1023 1023 return;
1024 1024 }
1025 + if (msg.type === 'projects') { // le registre mld a bougé : la liste des apps/nœuds suit
1026 + fillProjects(msg.projects || []);
1027 + loadRegistryInfo();
1028 + return;
1029 + }
1025 1030 if (msg.type === 'eco_alert') {
1031 + if (msg.kind === 'registry') {
1032 + banner('warn', `📦 ${esc(msg.reason || '')} — registre mld`, () => switchView('settings'), 12000);
1033 + loadRegistryInfo();
1034 + if (curView === 'eco') loadEco();
1035 + return;
1036 + }
1026 1037 if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); }
1027 1038 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
1028 1039 navBadge('eco');
@@ -1865,6 +1876,40 @@
1865 1876 };
1866 1877
1867 1878 // ---------- login / init ----------
1879 + // Sélecteur d'app : rempli depuis /api/projects (dérivé du registre mld) et RE-rempli
1880 + // quand le serveur annonce un changement de topologie (message WS `projects`).
1881 + function fillProjects(projects) {
1882 + const keep = projectSel.value || settings.projectId;
1883 + projectSel.innerHTML = '';
1884 + const allOpt = document.createElement('option');
1885 + allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + Math.max(0, projects.length - 1) + ')';
1886 + projectSel.appendChild(allOpt);
1887 + for (const p of projects) {
1888 + const o = document.createElement('option');
1889 + o.value = p.id; o.textContent = p.name + ' · ' + p.node;
1890 + projectSel.appendChild(o);
1891 + }
1892 + // un ancien id « app@nœud » reste valide côté serveur (résolu par app) ; côté UI on
1893 + // retrouve l'option de la même app si elle a changé de nœud
1894 + if (keep) {
1895 + const app = String(keep).split('@')[0];
1896 + const match = [...projectSel.options].find(o => o.value === keep) || [...projectSel.options].find(o => o.value.split('@')[0] === app);
1897 + if (match) { projectSel.value = match.value; if (settings.projectId !== match.value) { settings.projectId = match.value; saveStore(); } }
1898 + }
1899 + if (typeof refreshChips === 'function') refreshChips();
1900 + }
1901 + async function loadRegistryInfo() {
1902 + const el = $('setRegistry'), en = $('setRegistryNodes');
1903 + if (!el) return;
1904 + try {
1905 + const r = await (await fetch('/api/registry')).json();
1906 + const when = r.updated ? r.updated.replace('T', ' ') : 'jamais reçu';
1907 + const age = r.ageMs != null ? Math.round(r.ageMs / 60000) : null;
1908 + el.textContent = r.error ? `⚠ ${r.error}` : `${r.sites.length} apps Ka / ${r.nApps} au registre · mld ${when}${age != null ? ` · lu il y a ${age} min` : ''}${r.stale ? ' · ⚠ PÉRIMÉ' : ''}`;
1909 + el.title = `source : ${r.source || '?'} · cache ${r.cache || ''}${r.missing?.length ? ` · absentes du registre : ${r.missing.join(', ')}` : ''}`;
1910 + if (en) en.textContent = (r.nodes || []).map(n => n === r.self ? n + ' (console)' : n).join(' · ');
1911 + } catch (e) { el.textContent = 'indisponible'; }
1912 + }
1868 1913 async function init() {
1869 1914 const me = await fetch('/api/me');
1870 1915 if (!me.ok) {
@@ -1873,21 +1918,34 @@
1873 1918 return;
1874 1919 }
1875 1920 const info = await me.json();
1876 $('setHost').textContent = (info.node || 'M3U96a') + ':3300';
1921 + $('setHost').textContent = (info.node || '?') + ':3300';
1877 1922 $('login').classList.add('hidden');
1878 1923 $('app').classList.remove('hidden');
1879 1924 const pr = await fetch('/api/projects');
1880 1925 const { projects } = await pr.json();
1881 projectSel.innerHTML = '';
1882 const allOpt = document.createElement('option');
1883 allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + projects.length + ')';
1884 projectSel.appendChild(allOpt);
1885 for (const p of projects) {
1886 const o = document.createElement('option');
1887 o.value = p.id; o.textContent = p.name + ' · ' + p.node;
1888 projectSel.appendChild(o);
1889 }
1890 if (settings.projectId) projectSel.value = settings.projectId;
1926 + fillProjects(projects);
1927 + loadRegistryInfo();
1928 + const rb = $('btnRegRefresh');
1929 + if (rb) rb.onclick = async () => {
1930 + rb.disabled = true; rb.textContent = '↻ lecture…';
1931 + try {
1932 + const r = await fetch('/api/registry/refresh', { method: 'POST' });
1933 + const d = await r.json();
1934 + banner(d.ok ? 'good' : 'bad', d.ok ? `Registre relu (${esc(d.source || '')})${d.events?.length ? ' — ' + d.events.length + ' changement(s)' : ''}` : 'Registre injoignable : ' + esc(d.error || '?'));
1935 + const pr2 = await fetch('/api/projects'); fillProjects((await pr2.json()).projects || []);
1936 + } catch (e) { banner('bad', 'Échec : ' + e.message); }
1937 + rb.disabled = false; rb.textContent = '↻ Relire le registre (M1M32)'; loadRegistryInfo();
1938 + };
1939 + const tb = $('btnRegTooling');
1940 + if (tb) tb.onclick = async () => {
1941 + tb.disabled = true; tb.textContent = '🧰 vérification…';
1942 + try {
1943 + const d = await (await fetch('/api/registry/tooling', { method: 'POST' })).json();
1944 + const lines = (d.nodes || []).map(n => `${n.node} : ${n.local ? 'console (local)' : n.error ? '✗ ' + n.error : (n.actions && n.actions.length ? n.actions.join(' ; ') : 'ok')}`);
1945 + banner('good', lines.map(esc).join('<br>'), null, 15000);
1946 + } catch (e) { banner('bad', 'Échec : ' + e.message); }
1947 + tb.disabled = false; tb.textContent = '🧰 Vérifier l\'outillage des nœuds';
1948 + };
1891 1949 projectSel.onchange = () => {
1892 1950 settings.projectId = projectSel.value; saveStore(); refreshChips();
1893 1951 inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…'
modified public/index.html +11 −2
@@ -256,8 +256,17 @@ document.documentElement.dataset.theme=dark?'dark':'light';}catch(e){}})();</scr
256 256 </div>
257 257 </div>
258 258 <div class="set-card">
259 <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div>
260 <div class="set-row"><span>Version</span><span class="mono">3.0.0</span></div>
259 + <div class="set-label">Registre des emplacements (mld · passerelle M1M32)</div>
260 + <div class="set-row"><span>Registre</span><span id="setRegistry" class="mono">—</span></div>
261 + <div class="set-row"><span>Nœuds Ka</span><span id="setRegistryNodes" class="mono">—</span></div>
262 + <div class="set-actions">
263 + <button id="btnRegRefresh" class="btn-ghost">↻ Relire le registre (M1M32)</button>
264 + <button id="btnRegTooling" class="btn-ghost">🧰 Vérifier l'outillage des nœuds</button>
265 + </div>
266 + </div>
267 + <div class="set-card">
268 + <div class="set-row"><span>Backend</span><span id="setHost" class="mono">—</span></div>
269 + <div class="set-row"><span>Version</span><span class="mono">3.2.0</span></div>
261 270 </div>
262 271 <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
263 272 </section>
modified server/monitor.js +22 −18
@@ -6,9 +6,13 @@ import https from 'node:https';
6 6 import path from 'node:path';
7 7 import { execFile } from 'node:child_process';
8 8 import { DatabaseSync } from 'node:sqlite';
9 +import { getSites, getNodes, selfNode, sshTarget } from './registry.js';
9 10
10 // Sites du Groupe KA (source : cluster-deployments.json 2026-08-19)
11 export const SITES = [
11 +// Les sites surveillés et leurs nœuds viennent du REGISTRE mld (registry.js) —
12 +// plus aucune table figée ici : une app déplacée par `mld move` est suivie
13 +// automatiquement (health checks, sweep du bon nœud, actions pm2/launchd).
14 +// Historique (pour mémoire, table figée du 2026-08-19, devenue fausse le 2026-09-04) :
15 +const _SITES_LEGACY_2026_08 = [
12 16 { app: 'lou-ka', label: 'Lou·Ka', domain: 'www.lou-ka.com', node: 'M3U96b', pm2: 'lou-ka-web', dir: '~/apps/lou-ka', deployed: '2026-08' },
13 17 { app: 'immo-ka', label: 'Immo·Ka', domain: 'www.immo-ka.com', node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka', deployed: '2026-08' },
14 18 { app: 'house-ka', label: 'House·Ka', domain: 'www.house-ka.com', node: 'M4M64b', pm2: 'house-ka-web', dir: '~/apps/house-ka', deployed: '2026-08' },
@@ -31,8 +35,7 @@ export const SITES = [
31 35 { app: 'ka6', label: 'KA·6 Guardian', domain: 'www.ka6.bot', node: 'M4M36', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' },
32 36 ];
33 37
34 const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32', 'M4M36', 'M1M32'];
35 const SELF_NODE = 'M3U96a';
38 +void _SITES_LEGACY_2026_08; // conservé comme trace, jamais utilisé
36 39 const CHECK_MS = 45_000;
37 40 const SWEEP_MS = 5 * 60_000;
38 41 const SLOW_MS = 4000; // seuil "lent" (▲)
@@ -80,7 +83,7 @@ async function fetchIconFor(site) {
80 83 }
81 84 }
82 85 async function fetchAllIcons() {
83 await Promise.all(SITES.map(s => fetchIconFor(s).catch(() => {})));
86 + await Promise.all(getSites().map(s => fetchIconFor(s).catch(() => {})));
84 87 emit({ type: 'eco', kind: 'icons', ts: Date.now() });
85 88 }
86 89 export function getIcon(app) { return ICONS[app] || null; }
@@ -130,7 +133,7 @@ function checkOne(site) {
130 133 }
131 134
132 135 async function checkAll() {
133 const results = await Promise.all(SITES.map(checkOne));
136 + const results = await Promise.all(getSites().map(checkOne));
134 137 const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)');
135 138 for (const r of results) {
136 139 ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err, r.bytes || 0);
@@ -160,10 +163,10 @@ function handleIncident(r) {
160 163 // ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ----------
161 164 function sweepNode(node) {
162 165 return new Promise((resolve) => {
163 const gitCmds = SITES.filter(s => s.node === node)
164 .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');
166 + const gitCmds = getSites().filter(s => s.node === node)
167 + .map(s => `echo "GIT ${s.app} $(git -C ${s.repoDir || s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');
165 168 // sites sous launchd (KA Guardian) : PID + cpu/rss via ps
166 const lchCmds = SITES.filter(s => s.node === node && s.unit)
169 + const lchCmds = getSites().filter(s => s.node === node && s.unit)
167 170 .map(s => `pid=$(launchctl list | awk '$3=="${s.unit}"{print $1}'); [ -z "$pid" ] && pid=-; echo "LCH ${s.app} $pid $(ps -o %cpu=,rss= -p $pid 2>/dev/null | tr -s ' ')"`).join('; ');
168 171 const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
169 172 echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)"
@@ -174,10 +177,11 @@ echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')"
174 177 echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)"
175 178 ${lchCmds}
176 179 ${gitCmds}`;
177 const args = node === SELF_NODE
180 + const local = node === selfNode();
181 + const args = local
178 182 ? ['-c', script]
179 : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script];
180 const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';
183 + : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), script];
184 + const bin = local ? '/bin/bash' : 'ssh';
181 185 execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => {
182 186 if (err && !stdout) return resolve(null);
183 187 try { parseSweep(node, stdout); } catch {}
@@ -210,7 +214,7 @@ function parseSweep(node, out) {
210 214 lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) };
211 215 }
212 216 const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)');
213 for (const site of SITES.filter(s => s.node === node)) {
217 + for (const site of getSites().filter(s => s.node === node)) {
214 218 if (site.unit) {
215 219 const l = lch[site.app];
216 220 const up = l && l.pid !== '-' && /^\d+$/.test(l.pid);
@@ -231,7 +235,7 @@ function parseSweep(node, out) {
231 235 }
232 236
233 237 async function sweepNodes() {
234 await Promise.all(NODES.map(sweepNode));
238 + await Promise.all(getNodes().map(sweepNode));
235 239 emit({ type: 'eco', kind: 'sweep', ts: Date.now() });
236 240 }
237 241
@@ -298,7 +302,7 @@ function incidentStats(site) {
298 302
299 303 export function ecoSummary() {
300 304 const out = [];
301 for (const s of SITES) {
305 + for (const s of getSites()) {
302 306 const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app);
303 307 const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse();
304 308 const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000);
@@ -346,7 +350,7 @@ export function ecoSummary() {
346 350 }
347 351
348 352 export function ecoSite(app) {
349 const s = SITES.find(x => x.app === app);
353 + const s = getSites().find(x => x.app === app);
350 354 if (!s) return null;
351 355 const since = Date.now() - 24 * 3600_000;
352 356 // ~200 seaux sur 24 h pour le graphique
@@ -384,10 +388,10 @@ export function ecoIncidents() {
384 388
385 389 export function ecoNodes() {
386 390 const out = [];
387 for (const node of NODES) {
391 + for (const node of getNodes()) {
388 392 const last = db.prepare('SELECT ts,load1,mem_used,mem_total,disk_pct FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(node);
389 393 const hist = db.prepare('SELECT ts,load1,mem_used,mem_total FROM nodestats WHERE node=? AND ts>? ORDER BY ts').all(node, Date.now() - 24 * 3600_000);
390 const apps = SITES.filter(s => s.node === node).map(s => {
394 + const apps = getSites().filter(s => s.node === node).map(s => {
391 395 const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);
392 396 return { app: s.app, label: s.label, ...p };
393 397 });
added server/registry.js +395 −0
@@ -0,0 +1,395 @@
1 +// registry.js — topologie VIVANTE de la console admin-ka.
2 +// « Quelle app tourne sur quel nœud ? » n'est plus codé ici : la réponse vient
3 +// du registre de la passerelle mld (M1M32:~/dispatch/registry.json), que
4 +// l'orchestrateur maclustr-dispatch met à jour à chaque deploy/move/retire.
5 +// • poussé par mld dans data/registry.json (abonné « admin-ka », suit les migrations) ;
6 +// • tiré toutes les 2 min par ssh (M1M32 / gitsrv) en ceinture-et-bretelles ;
7 +// • le fichier local est surveillé : un push mld est appliqué en quelques secondes.
8 +// Ce module ne garde en dur que la CONNAISSANCE Ka (libellés, exceptions launchd,
9 +// process PM2 non déductibles) — jamais un emplacement.
10 +import fs from 'node:fs';
11 +import os from 'node:os';
12 +import path from 'node:path';
13 +import { execFile } from 'node:child_process';
14 +import { fileURLToPath } from 'node:url';
15 +
16 +const __dirname = path.dirname(fileURLToPath(import.meta.url));
17 +const HOME = os.homedir();
18 +const USER = 'simon-pierreboucher';
19 +const GATEWAY_HOSTS = ['M1M32', 'gitsrv']; // alias ssh essayés pour tirer le registre
20 +const PULL_MS = 2 * 60_000;
21 +const WATCH_MS = 5_000;
22 +const STALE_MS = 3 * 3600_000; // registre plus vieux → « périmé » (mld en panne ?)
23 +const ENV_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
24 +
25 +// ---------- connaissance Ka (PAS d'emplacement ici) ----------
26 +// label : nom affiché ; pm2 : process web quand la règle « *-web sinon 1er » ne suffit pas ;
27 +// unit/log : apps sous launchd (pas de pm2) ; repoDir : repo de travail ≠ dir du manifeste ;
28 +// monitor:false / project:false : exclure du monitoring / des projets Claude Code.
29 +export const KA_CATALOG = {
30 + 'lou-ka': { label: 'Lou·Ka' },
31 + 'rent-ka': { label: 'Rent·Ka' },
32 + 'immo-ka': { label: 'Immo·Ka' },
33 + 'house-ka': { label: 'House·Ka' },
34 + 'vrai-prix': { label: 'Vrai-Prix' },
35 + 'auto-ka': { label: 'Auto·Ka' },
36 + 'fabri-ka': { label: 'Fabri·Ka' },
37 + 'food-ka': { label: 'Food·Ka' },
38 + 'resto-ka': { label: 'Resto·Ka' },
39 + 'sorti-ka': { label: 'Sorti·Ka' },
40 + 'crea-ka': { label: 'Créa·Ka' },
41 + 'job-ka': { label: 'Job·Ka' },
42 + 'trouve-ka': { label: 'Trouve·Ka', pm2: 'tk-web' },
43 + 'groupe-ka': { label: 'Groupe·Ka' },
44 + 'api-ka': { label: 'API·Ka', pm2: 'apika-api' },
45 + 'ka-stats': { label: 'Ka·Stats' },
46 + // Agents gardiens KA Guardian — un seul repo, launchd (pas de pm2)
47 + 'ka2': { label: 'KA·2 Guardian', unit: 'com.ka2.guardian', log: '~/cluster-projects/ka-guardian/logs/ka2.log', repoDir: '~/cluster-projects/ka-guardian' },
48 + 'ka4': { label: 'KA·4 Guardian', unit: 'com.ka4.guardian', log: '~/cluster-projects/ka-guardian/logs/ka4.log', repoDir: '~/cluster-projects/ka-guardian' },
49 + 'ka6': { label: 'KA·6 Guardian', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', repoDir: '~/cluster-projects/ka-guardian' },
50 + // la console elle-même : sert à détecter SON nœud, ni surveillée ni projet
51 + 'admin-ka': { label: 'Administration·Ka', monitor: false, project: false },
52 +};
53 +// toute app du registre au nom « Ka » (ex. un futur toit-ka) est incluse automatiquement
54 +const KA_NAME_RE = /(^|-)ka(\d|-|$)/i;
55 +
56 +// ---------- état ----------
57 +export const SITES = []; // rempli EN PLACE (les importateurs gardent leur référence)
58 +let REG = { apps: {}, updated: null, gateway: null, fetchedAt: 0, source: null, error: null, mtime: 0 };
59 +let SELF = 'M3U96a'; // repli tant que le registre n'a pas dit où tourne admin-ka
60 +let cachePath = null;
61 +let onChange = () => {};
62 +let sshHosts = new Set();
63 +const toolingChecked = {}; // node -> ts du dernier contrôle outillage
64 +
65 +function expandHome(p) { return p && p.startsWith('~') ? path.join(HOME, p.slice(1)) : p; }
66 +function loadSshHosts() {
67 + try {
68 + const txt = fs.readFileSync(path.join(HOME, '.ssh', 'config'), 'utf8');
69 + sshHosts = new Set([...txt.matchAll(/^Host\s+(\S+)\s*$/gm)].map(m => m[1]).filter(h => h !== '*'));
70 + } catch { sshHosts = new Set(); }
71 +}
72 +
73 +// ---------- construction des sites depuis le registre ----------
74 +function webProcess(app, processes, cat) {
75 + if (cat.pm2) return cat.pm2;
76 + const biz = processes.filter(p => !/(^|-)ngrok$/.test(p));
77 + return biz.find(p => /-web$/.test(p)) || biz.find(p => p === app) || biz[0] || null;
78 +}
79 +
80 +function buildSites() {
81 + const apps = REG.apps || {};
82 + const names = [
83 + ...Object.keys(KA_CATALOG).filter(a => apps[a]),
84 + ...Object.keys(apps).filter(a => !KA_CATALOG[a] && KA_NAME_RE.test(a)).sort(),
85 + ];
86 + const next = [];
87 + for (const app of names) {
88 + const cat = KA_CATALOG[app] || {};
89 + const e = apps[app];
90 + if (cat.monitor === false && cat.project === false) continue;
91 + const processes = Array.isArray(e.processes) ? e.processes : [];
92 + const launchd = Array.isArray(e.launchd) ? e.launchd : [];
93 + const unit = cat.unit || (processes.length === 0 && launchd.length ? launchd[0] : null);
94 + next.push({
95 + app, label: cat.label || e.label || app,
96 + domain: e.domain || null, node: e.node, ip: e.ip || null, port: e.port || null,
97 + dir: e.dir, repoDir: cat.repoDir || e.dir,
98 + pm2: unit ? null : webProcess(app, processes, cat),
99 + unit, log: cat.log || null,
100 + processes, launchd,
101 + deployed: e.deployed || null, status: e.status || null, updated: e.updated || null,
102 + monitor: cat.monitor !== false, project: cat.project !== false,
103 + });
104 + }
105 + return next;
106 +}
107 +
108 +function diffSites(before, after) {
109 + const ev = [];
110 + const b = new Map(before.map(s => [s.app, s]));
111 + const a = new Map(after.map(s => [s.app, s]));
112 + for (const [app, s] of a) {
113 + const o = b.get(app);
114 + if (!o) ev.push({ kind: 'added', app, label: s.label, to: s.node });
115 + else if (o.node !== s.node) ev.push({ kind: 'moved', app, label: s.label, from: o.node, to: s.node });
116 + else if (o.dir !== s.dir || o.port !== s.port) ev.push({ kind: 'changed', app, label: s.label, node: s.node, dir: s.dir, port: s.port });
117 + }
118 + for (const [app, o] of b) if (!a.has(app)) ev.push({ kind: 'removed', app, label: o.label, from: o.node });
119 + return ev;
120 +}
121 +
122 +function applyRegistry(data, source, mtime) {
123 + const apps = data && data.apps;
124 + if (!apps || typeof apps !== 'object' || !Object.keys(apps).length) throw new Error('registre sans apps');
125 + const before = SITES.slice();
126 + const prevSelf = SELF;
127 + REG = { apps, updated: data.updated || null, gateway: data.gateway || null, fetchedAt: Date.now(), source, error: null, mtime: mtime || Date.now() };
128 + if (apps['admin-ka'] && apps['admin-ka'].node) SELF = apps['admin-ka'].node;
129 + const next = buildSites();
130 + SITES.length = 0; SITES.push(...next);
131 + const events = diffSites(before, next);
132 + if (prevSelf !== SELF) events.push({ kind: 'self', from: prevSelf, to: SELF });
133 + return events;
134 +}
135 +
136 +// ---------- cache local (poussé par mld) ----------
137 +function readCache() {
138 + if (!cachePath || !fs.existsSync(cachePath)) return false;
139 + const st = fs.statSync(cachePath);
140 + if (st.mtimeMs === REG.mtime && SITES.length) return false;
141 + const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
142 + const events = applyRegistry(data, 'cache:' + cachePath, st.mtimeMs);
143 + return events;
144 +}
145 +function writeCache(text) {
146 + const tmp = cachePath + '.tmp';
147 + fs.writeFileSync(tmp, text);
148 + fs.renameSync(tmp, cachePath);
149 +}
150 +
151 +// ---------- tirage ssh ----------
152 +function sshCat(host) {
153 + return new Promise((resolve) => {
154 + execFile('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, 'cat ~/dispatch/registry.json'],
155 + { timeout: 25_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: ENV_PATH, HOME } },
156 + (err, stdout, stderr) => resolve(err ? { error: (stderr || err.message).trim().slice(0, 200) } : { text: String(stdout) }));
157 + });
158 +}
159 +
160 +export async function refreshRegistry(reason = 'periodic') {
161 + let lastErr = null;
162 + for (const host of GATEWAY_HOSTS) {
163 + if (!sshHosts.has(host) && host !== 'gitsrv') continue;
164 + const r = await sshCat(host);
165 + if (r.error) { lastErr = `${host}: ${r.error}`; continue; }
166 + let data;
167 + try { data = JSON.parse(r.text); } catch (e) { lastErr = `${host}: JSON invalide (${e.message})`; continue; }
168 + try {
169 + const events = applyRegistry(data, `ssh:${host}`, Date.now());
170 + if (cachePath) { try { writeCache(JSON.stringify(data, null, 2)); REG.mtime = fs.statSync(cachePath).mtimeMs; } catch {} }
171 + finish(events, reason);
172 + return { ok: true, source: `ssh:${host}`, events };
173 + } catch (e) { lastErr = `${host}: ${e.message}`; }
174 + }
175 + REG.error = lastErr || 'aucun hôte passerelle';
176 + return { ok: false, error: REG.error };
177 +}
178 +
179 +function finish(events, reason) {
180 + if (events && events.length) {
181 + for (const ev of events) console.log(`[registre] ${describe(ev)} (${reason})`);
182 + try { onChange(events); } catch {}
183 + }
184 +}
185 +
186 +export function describe(ev) {
187 + switch (ev.kind) {
188 + case 'moved': return `${ev.label} a déménagé ${ev.from} → ${ev.to}`;
189 + case 'added': return `${ev.label} apparaît dans le registre (${ev.to})`;
190 + case 'removed': return `${ev.label} a quitté le registre (était sur ${ev.from})`;
191 + case 'changed': return `${ev.label} : répertoire/port changés sur ${ev.node} (${ev.dir}:${ev.port})`;
192 + case 'self': return `la console admin-ka tourne maintenant sur ${ev.to} (avant ${ev.from})`;
193 + default: return JSON.stringify(ev);
194 + }
195 +}
196 +
197 +// ---------- API pour le reste du backend ----------
198 +export function selfNode() { return SELF; }
199 +export function getNodes() {
200 + const set = new Set(SITES.filter(s => s.monitor).map(s => s.node));
201 + set.add(SELF);
202 + return [...set].sort();
203 +}
204 +export function ipOf(node) {
205 + for (const s of SITES) if (s.node === node && s.ip) return s.ip;
206 + for (const e of Object.values(REG.apps || {})) if (e.node === node && e.ip) return e.ip;
207 + return null;
208 +}
209 +// cible ssh d'un nœud : l'alias de ~/.ssh/config s'il existe (noms Bonjour, résistants au
210 +// DHCP), sinon user@IP LAN du registre (un nœud jamais vu par cette config reste joignable).
211 +export function sshTarget(node) {
212 + if (sshHosts.has(node)) return node;
213 + const ip = ipOf(node);
214 + return ip ? `${USER}@${ip}` : node;
215 +}
216 +export function getSites() { return SITES.filter(s => s.monitor); }
217 +export function getProjects() {
218 + return SITES.filter(s => s.project).map(s => ({
219 + id: `${s.app}@${s.node}`, app: s.app, name: s.label,
220 + node: s.node === SELF ? null : s.node,
221 + dir: s.node === SELF ? expandHome(s.repoDir) : s.repoDir,
222 + nodeLabel: s.node,
223 + }));
224 +}
225 +// accepte les anciens ids « app@nœud » même si l'app a déménagé depuis
226 +export function findProject(id) {
227 + if (!id) return null;
228 + const app = String(id).split('@')[0];
229 + return getProjects().find(p => p.app === app) || null;
230 +}
231 +export function getRegistry() {
232 + const age = REG.fetchedAt ? Date.now() - REG.fetchedAt : null;
233 + const missing = Object.keys(KA_CATALOG).filter(a => KA_CATALOG[a].monitor !== false && !(REG.apps || {})[a]);
234 + return {
235 + updated: REG.updated, gateway: REG.gateway, fetchedAt: REG.fetchedAt, source: REG.source, error: REG.error,
236 + ageMs: age, stale: age == null || age > STALE_MS, nApps: Object.keys(REG.apps || {}).length,
237 + self: SELF, nodes: getNodes(), missing, cache: cachePath,
238 + sites: SITES.map(s => ({ app: s.app, label: s.label, node: s.node, ip: s.ip, port: s.port, dir: s.dir, repoDir: s.repoDir, pm2: s.pm2, unit: s.unit, processes: s.processes, status: s.status, deployed: s.deployed, updated: s.updated, domain: s.domain })),
239 + };
240 +}
241 +
242 +// Bloc texte injecté dans les prompts système (upgradeur, orchestrateur) : jamais de table figée.
243 +export function topologyText() {
244 + const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu';
245 + const lines = SITES.map(s => `- ${s.app} → nœud ${s.node}${s.node === SELF ? ' (local)' : ''}, repo ${s.repoDir}${s.pm2 ? `, pm2 ${s.processes.filter(p => !/ngrok$/.test(p)).join('/')}` : s.unit ? `, launchd ${s.unit}` : ''}${s.port ? `, port ${s.port}` : ''}`);
246 + return `Emplacements ACTUELS des apps (registre mld de la passerelle M1M32, mis à jour ${when} — les apps peuvent être déplacées par \`mld move\`, ne jamais supposer un nœud de mémoire ; pour vérifier : \`ssh gitsrv cat ~/dispatch/registry.json\`) :\n${lines.join('\n')}`;
247 +}
248 +
249 +// ---------- CLAUDE.md de l'orchestrateur multi-sites (régénéré à chaque changement) ----------
250 +export function orchestratorDoc() {
251 + const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu';
252 + const rows = SITES.map(s => `| ${s.app.padEnd(10)} | ${s.node.padEnd(7)} | ${s.repoDir.padEnd(34)} | ${s.unit ? `launchd ${s.unit}` : s.processes.filter(p => !/ngrok$/.test(p)).join(', ')} |`).join('\n');
253 + const locals = SITES.filter(s => s.node === SELF).map(s => s.app).join(', ') || '(aucune)';
254 + const nodes = getNodes().filter(n => n !== SELF);
255 + return `# 🎛️ Orchestrateur multi-sites Groupe KA
256 +
257 +Tu es une session Claude Code **orchestratrice** lancée sur le nœud **${SELF}** (là où tourne la console administration-ka.com). Ton rôle : réaliser une tâche qui touche **plusieurs apps du Groupe KA à la fois**, en gardant le **contexte complet** d'un bout à l'autre (contrairement à la diffusion qui lance une session isolée par app). Tu travailles sur les repos de PROD **à distance via SSH** sur le nœud de chaque app.
258 +
259 +## Table de déploiement (GÉNÉRÉE depuis le registre mld — ne pas éditer à la main)
260 +
261 +Source de vérité : \`M1M32:~/dispatch/registry.json\` (mis à jour ${when}). Ce fichier est régénéré par admin-ka à chaque changement du registre ; en cas de doute : \`ssh gitsrv cat ~/dispatch/registry.json\` ou \`ssh M1M32 ~/maclustr-dispatch/bin/mld status\`.
262 +
263 +| App | Nœud | Répertoire de prod | Process |
264 +|------------|---------|------------------------------------|---------|
265 +${rows}
266 +
267 +- Les apps sur **${SELF}** (${locals}) : tu es déjà dessus → travaille en local (\`cd <dir>\`), pas de SSH.
268 +- Les autres : \`ssh <nœud> 'cd <dir> && …'\`. Alias SSH configurés sur ${SELF} pour ${nodes.join(', ')} (noms Bonjour .local en interne). ⚠️ *.maclustr.io est bloqué entre nœuds ; utilise les alias, ou \`simon-pierreboucher@<IP LAN>\` du registre pour un nœud sans alias.
269 +- Pour copier un fichier vers un nœud : \`scp fichier <nœud>:<dir>/\` ou édite via un heredoc en SSH.
270 +- Une app peut être **déplacée par \`mld move\`** entre deux de tes tours : relis cette table (elle est réécrite) plutôt que de te fier à ta mémoire.
271 +
272 +## Méthode d'orchestration (garde le contexte de TOUTE la tâche)
273 +
274 +1. **Planifie d'abord** : liste les apps concernées et ce qui doit changer sur chacune. Utilise TodoWrite pour suivre l'avancement app par app.
275 +2. **Explore avant d'agir** : sur 1-2 apps représentatives, lis le code pour comprendre le pattern commun (les apps KA partagent beaucoup : ka-ui, ecosystem.json, structure back/front). Décide d'une approche cohérente réutilisable partout.
276 +3. **Applique app par app** (ou par nœud, en parallèle avec \`&\`+\`wait\` quand c'est sûr). Pour chaque app : édite le repo de prod, build si besoin, \`pm2 restart <process>\`, vérifie le healthcheck/site, puis \`git add/commit/push origin main\` (origin = spbgit).
277 +4. **Rapporte un bilan final** : tableau des apps modifiées, commits, et ce qui reste.
278 +
279 +## Conventions (rappel)
280 +- Remote-first, origin = **spbgit** (git perso sur la passerelle M1M32, alias \`gitsrv\` — PAS GitHub). Standards UI KA (header/nav opaques, --z-*, mobile-first, ordre DOM=visuel des fiches). i18n fr-CA.
281 +- Ne jamais laisser une app avec des modifs non commitées / non redéployées.
282 +- Outils dispo sur chaque nœud (dans ~/.claude/.env) : Serper, Tavily, Scrapfly, Bright Data, Oxylabs, Apify — pour les tâches de connecteurs.
283 +- Sudo si besoin : \`echo '492592' | sudo -S <cmd>\`.
284 +
285 +## Process (colonne « Process » ci-dessus : pm2 sauf mention launchd)
286 +Les tunnels \`<app>-ngrok\` ne sont jamais redémarrés (ils portent le domaine). En cas de doute : \`pm2 jlist\` sur le nœud. EXCEPTION KA Guardian (ka2/ka4/ka6) : launchd, pas pm2 — restart via \`launchctl kickstart -k gui/$(id -u)/com.ka2.guardian\` (idem ka4/ka6), health \`curl localhost:8799|8899|8999/health\` ; voir le CLAUDE.md du repo ka-guardian.
287 +
288 +Travaille avec rigueur : une tâche multi-sites bien orchestrée > 13 modifs incohérentes.
289 +`;
290 +}
291 +export function writeOrchestratorDoc(file) {
292 + try {
293 + const next = orchestratorDoc();
294 + let cur = null;
295 + try { cur = fs.readFileSync(file, 'utf8'); } catch {}
296 + if (cur === next) return false;
297 + fs.mkdirSync(path.dirname(file), { recursive: true });
298 + fs.writeFileSync(file, next);
299 + console.log(`[registre] ${file} régénéré`);
300 + return true;
301 + } catch (e) { console.error('[registre] CLAUDE.md orchestrateur :', e.message); return false; }
302 +}
303 +
304 +// ---------- outillage des nœuds distants (perm-mcp + CLAUDE.md contextuel) ----------
305 +function nodeClaudeMd(node) {
306 + const hosted = SITES.filter(s => s.node === node).map(s => `${s.app} (${s.repoDir}${s.unit ? `, launchd ${s.unit}` : s.processes.length ? `, pm2 ${s.processes.filter(p => !/ngrok$/.test(p)).join('/')}` : ''})`);
307 + return `# ⚠️ TU ES SUR LE NŒUD ${node} DU CLUSTER MACLUSTR (pas le laptop)
308 +
309 +Tu es Claude Code exécuté **sur ${node}**, lancé à distance depuis administration-ka.com (console sur ${SELF}). Le répertoire courant est un **repo de production déployé sur CE nœud** : c'est la source de vérité (doctrine remote-first Groupe Ka).
310 +
311 +- Après toute modification : build si nécessaire, \`pm2 restart <app>\`, vérifier le healthcheck, puis \`git add/commit/push origin main\` (origin = gitsrv/spbgit, le git perso sur la passerelle M1M32 — PAS GitHub).
312 +- **Où sont les autres apps ?** Le registre mld fait foi, jamais une table en dur : \`ssh gitsrv cat ~/dispatch/registry.json\` (nœud, IP LAN, dir, port, process PM2 de chaque app).
313 +- ⚠️ Depuis un nœud, SSH vers *.maclustr.io est BLOQUÉ (ACL Tailscale). Seul l'alias \`gitsrv\` (M1M32) est garanti ; les autres nœuds par leur IP LAN 192.168.2.x du registre, si la clé locale y est autorisée.
314 +- Clés API (Anthropic, Serper, Tavily, Scrapfly, Bright Data, Oxylabs, Apify) : \`~/.claude/.env\`, exportées dans tes sessions. Ne jamais installer de paquets globaux : venv / node_modules du repo.
315 +- Sudo local : \`echo '492592' | sudo -S <cmd>\`.
316 +- Apps hébergées ici d'après le registre (${REG.updated || 'date inconnue'}) : ${hosted.join(' ; ') || '(aucune connue)'}.
317 +`;
318 +}
319 +function sshExec(target, script, timeoutMs = 30_000) {
320 + return new Promise((resolve) => {
321 + execFile('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', target, script],
322 + { timeout: timeoutMs, maxBuffer: 2 * 1024 * 1024, env: { ...process.env, PATH: ENV_PATH, HOME } },
323 + (err, stdout, stderr) => resolve({ rc: err ? (err.code ?? 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));
324 + });
325 +}
326 +const toolingInflight = {}; // node -> Promise (évite deux contrôles concurrents au démarrage)
327 +export function ensureNodeTooling(node, opts = {}) {
328 + if (toolingInflight[node]) return toolingInflight[node];
329 + const p = _ensureNodeTooling(node, opts).finally(() => { delete toolingInflight[node]; });
330 + toolingInflight[node] = p;
331 + return p;
332 +}
333 +async function _ensureNodeTooling(node, { force = false } = {}) {
334 + if (node === SELF) return { node, local: true };
335 + if (!force && Date.now() - (toolingChecked[node] || 0) < 6 * 3600_000) return { node, cached: true };
336 + const target = sshTarget(node);
337 + const local = path.join(__dirname, 'perm-mcp.js');
338 + const probe = await sshExec(target, `md5 -q ~/.adminka/perm-mcp.js 2>/dev/null || echo none; test -s ~/.claude/CLAUDE.md && echo claude-md:ok || echo claude-md:none; test -x /opt/homebrew/bin/claude && echo claude:ok || echo claude:none; test -s ~/.claude/.env && echo env:ok || echo env:none`);
339 + if (probe.rc !== 0) return { node, error: `ssh ${target} : ${probe.err.slice(0, 160)}` };
340 + toolingChecked[node] = Date.now();
341 + const lines = probe.out.trim().split('\n');
342 + const result = { node, target, actions: [] };
343 + const localMd5 = await new Promise(r => execFile('md5', ['-q', local], (e, o) => r(e ? null : String(o).trim())));
344 + if (localMd5 && lines[0] !== localMd5) {
345 + const src = fs.readFileSync(local, 'utf8');
346 + const w = await sshExec(target, `mkdir -p ~/.adminka && cat > ~/.adminka/perm-mcp.js <<'__ADMINKA_EOF__'\n${src}\n__ADMINKA_EOF__\necho ok`);
347 + result.actions.push(w.out.includes('ok') ? 'perm-mcp.js installé/mis à jour' : `perm-mcp.js ÉCHEC ${w.err.slice(0, 100)}`);
348 + }
349 + if (lines.includes('claude-md:none')) {
350 + const md = nodeClaudeMd(node);
351 + const w = await sshExec(target, `mkdir -p ~/.claude && cat > ~/.claude/CLAUDE.md <<'__ADMINKA_EOF__'\n${md}\n__ADMINKA_EOF__\necho ok`);
352 + result.actions.push(w.out.includes('ok') ? 'CLAUDE.md contextuel créé' : `CLAUDE.md ÉCHEC ${w.err.slice(0, 100)}`);
353 + }
354 + result.claude = lines.includes('claude:ok');
355 + result.env = lines.includes('env:ok');
356 + if (!result.claude) result.actions.push('⚠ /opt/homebrew/bin/claude ABSENT — les sessions Claude Code y échoueront (npm i -g @anthropic-ai/claude-code)');
357 + if (!result.env) result.actions.push('⚠ ~/.claude/.env ABSENT — copier les clés API');
358 + if (result.actions.length) console.log(`[registre] outillage ${node} : ${result.actions.join(' ; ')}`);
359 + return result;
360 +}
361 +export async function ensureAllNodesTooling(opts) {
362 + const out = [];
363 + for (const n of getNodes()) out.push(await ensureNodeTooling(n, opts));
364 + return out;
365 +}
366 +
367 +// ---------- démarrage ----------
368 +export async function initRegistry({ dataDir, onChange: cb, orchestratorDocPath } = {}) {
369 + cachePath = path.join(dataDir, 'registry.json');
370 + onChange = (events) => {
371 + if (orchestratorDocPath) writeOrchestratorDoc(orchestratorDocPath);
372 + ensureAllNodesTooling().catch(() => {});
373 + cb && cb(events);
374 + };
375 + loadSshHosts();
376 + let events = [];
377 + try { events = readCache() || []; } catch (e) { console.error('[registre] cache illisible :', e.message); }
378 + if (!SITES.length) {
379 + const r = await refreshRegistry('startup');
380 + if (!r.ok) console.error('[registre] INDISPONIBLE au démarrage :', r.error, '— aucun site connu tant que le registre ne répond pas');
381 + } else {
382 + console.log(`[registre] ${SITES.length} apps Ka depuis le cache (registre ${REG.updated}), nœud local ${SELF}`);
383 + refreshRegistry('startup-refresh').catch(() => {});
384 + }
385 + if (orchestratorDocPath) writeOrchestratorDoc(orchestratorDocPath);
386 + ensureAllNodesTooling().catch(() => {});
387 + if (events.length) finish(events, 'startup');
388 + // 1) le push mld modifie data/registry.json → application en quelques secondes
389 + fs.watchFile(cachePath, { interval: WATCH_MS }, () => {
390 + try { const ev = readCache(); if (ev) finish(ev, 'push mld'); } catch (e) { console.error('[registre] cache :', e.message); }
391 + });
392 + // 2) tirage périodique (si un push a raté ou si mld tourne sans abonné)
393 + setInterval(() => { refreshRegistry('periodic').catch(() => {}); }, PULL_MS);
394 + setInterval(loadSshHosts, 30 * 60_000);
395 +}
modified server/server.js +89 −55
@@ -10,7 +10,9 @@ import os from 'node:os';
10 10 import { spawn } from 'node:child_process';
11 11 import { fileURLToPath } from 'node:url';
12 12 import { WebSocketServer } from 'ws';
13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
13 +import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon } from './monitor.js';
14 +// Topologie vivante : où tourne chaque app Ka = registre mld (M1M32), jamais codé ici.
15 +import { initRegistry, refreshRegistry, getRegistry, getSites, getProjects, findProject, selfNode, sshTarget, topologyText, describe as describeRegistryEvent, ensureAllNodesTooling } from './registry.js';
14 16 import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js';
15 17 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';
16 18 import { execFile } from 'node:child_process';
@@ -60,7 +62,8 @@ const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8';
60 62 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é.
61 63
62 64 # É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. house-ka (house-ka.com) : maisons à vendre Canada hors Québec, site EN (M4M64b, pm2 house-ka-web). 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).
65 +- 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. house-ka (house-ka.com) : maisons à vendre Canada hors Québec, site EN. 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. 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. 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).
66 +- 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.
64 67 - 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).
65 68
66 69 # Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent)
@@ -85,7 +88,7 @@ const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration
85 88
86 89 async function upgradePrompt(rawPrompt, project) {
87 90 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}).` : '';
91 + 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}).` : '';
89 92 const resp = await fetch('https://api.anthropic.com/v1/messages', {
90 93 method: 'POST',
91 94 headers: {
@@ -96,7 +99,7 @@ async function upgradePrompt(rawPrompt, project) {
96 99 body: JSON.stringify({
97 100 model: UPGRADER_MODEL,
98 101 max_tokens: 1500,
99 system: KA_KNOWLEDGE,
102 + system: KA_KNOWLEDGE + '\n\n# ' + topologyText(),
100 103 messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }],
101 104 }),
102 105 });
@@ -204,42 +207,39 @@ let prompts = [];
204 207 try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {}
205 208 function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); }
206 209
207 // ---------- projets (12 apps Groupe KA) ----------
208 const SELF_NODE = 'M3U96a';
209 const 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: 'house-ka@M4M64b', name: 'House·Ka', node: 'M4M64b', dir: '~/apps/house-ka' },
215 { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },
216 { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },
217 { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },
218 { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },
219 { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },
220 { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },
221 { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },
222 { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },
223 { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },
224 { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },
225 { id: 'ka-stats@M1M32', name: 'Ka·Stats', node: 'M1M32', dir: '~/apps/ka-stats' },
226 // Agents gardiens KA Guardian — 3 sites, 1 seul repo sur M4M36 (launchd, pas pm2)
227 { id: 'ka2@M4M36', name: 'KA·2 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
228 { id: 'ka4@M4M36', name: 'KA·4 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
229 { id: 'ka6@M4M36', name: 'KA·6 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
230 ];
231 // Orchestrateur : une seule session sur M3U96a qui pilote les 13 apps via SSH,
232 // en gardant le contexte complet de la tâche.
233 const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: path.join(HOME, 'ka-orchestrator') };
210 +// ---------- projets (apps Groupe KA) ----------
211 +// La liste des projets Claude Code (app → nœud → repo de prod) est DÉRIVÉE du registre
212 +// mld via registry.js (getProjects/findProject) : quand `mld move` déplace une app, la
213 +// prochaine session s'ouvre sur le bon nœud. Plus aucune table figée ici.
214 +// Orchestrateur : une seule session sur le nœud de la console qui pilote toutes les apps
215 +// via SSH, en gardant le contexte complet de la tâche (CLAUDE.md régénéré par registry.js).
216 +const ORCH_DIR = path.join(HOME, 'ka-orchestrator');
217 +const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: ORCH_DIR };
234 218 function listProjects() {
235 219 return [
236 { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: SELF_NODE, dir: ORCH_PROJECT.dir },
237 ...PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })),
220 + { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: selfNode(), dir: ORCH_PROJECT.dir },
221 + ...getProjects().map(p => ({ id: p.id, name: p.name, node: p.node || selfNode(), dir: p.dir })),
238 222 ];
239 223 }
240 function findProject(id) {
224 +function resolveProject(id) {
241 225 if (id === 'ORCH') return ORCH_PROJECT;
242 return PROJECTS.find(p => p.id === id) || null;
226 + return findProject(id);
227 +}
228 +// Une conversation garde node/dir de sa création ; si l'app a déménagé depuis (registre),
229 +// on la rattache au nœud actuel et on repart d'une session Claude vierge (la session
230 +// `--resume` vivait sur l'ancien nœud).
231 +function realignChat(chat) {
232 + if (!chat || chat.projectId === 'ORCH') return false;
233 + const proj = resolveProject(chat.projectId);
234 + if (!proj) return false;
235 + if (proj.node === chat.node && proj.dir === chat.dir) return false;
236 + const from = chat.node || selfNode(), to = proj.node || selfNode();
237 + chat.projectId = proj.id; chat.node = proj.node; chat.dir = proj.dir;
238 + chat.claudeSessionId = null; chat.contextTokens = 0;
239 + saveChats();
240 + 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}).` });
241 + audit({ kind: 'chat_realigned', chatId: chat.id, project: chat.projectId, from, to });
242 + return true;
243 243 }
244 244
245 245 // ---------- exécution Claude Code ----------
@@ -252,7 +252,7 @@ const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,Noteboo
252 252
253 253 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 <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.`;
254 254
255 const 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.`;
255 +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 <nœud> 'cd <dir> && …') ; 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()}`;
256 256
257 257 const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;
258 258
@@ -301,7 +301,8 @@ function startClaude(chat, prompt) {
301 301 const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';
302 302 let proc;
303 303
304 const sysAppend = chat.projectId === 'ORCH' ? ORCH_SYSTEM : SYSTEM_APPEND;
304 + realignChat(chat); // l'app a-t-elle déménagé depuis la dernière fois ? (registre mld)
305 + const sysAppend = chat.projectId === 'ORCH' ? orchSystem() : SYSTEM_APPEND + '\n\n' + topologyText();
305 306 const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
306 307 '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend];
307 308 if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);
@@ -372,7 +373,7 @@ function startClaude(chat, prompt) {
372 373 `echo $$ > /tmp/adminka-pid-${permToken}`,
373 374 cmd,
374 375 ].join(' && ');
375 proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], {
376 + proc = spawn('ssh', [...sshArgs, sshTarget(chat.node), remoteCmd], {
376 377 env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },
377 378 stdio: ['pipe', 'pipe', 'pipe'],
378 379 });
@@ -382,7 +383,7 @@ function startClaude(chat, prompt) {
382 383
383 384 const state = { proc, permToken, stderr: '', remote: chat.node || null };
384 385 running.set(chat.id, state);
385 audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });
386 + audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || selfNode(), dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });
386 387 emitAndLog(chat.id, { type: 'status', state: 'running' });
387 388 setChatState(chat, 'running');
388 389
@@ -455,7 +456,7 @@ function interrupt(chatId) {
455 456 if (!st) return;
456 457 audit({ kind: 'interrupt', chatId });
457 458 if (st.remote) {
458 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}`], {
459 + 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}`], {
459 460 env: { ...process.env, HOME }, stdio: 'ignore',
460 461 });
461 462 }
@@ -467,8 +468,9 @@ function interrupt(chatId) {
467 468 function runOnNode(node, script) {
468 469 return new Promise((resolve) => {
469 470 const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`;
470 const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';
471 const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full];
471 + const local = node === selfNode();
472 + const bin = local ? '/bin/bash' : 'ssh';
473 + const args = local ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), full];
472 474 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 } },
473 475 (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null }));
474 476 });
@@ -583,8 +585,23 @@ const server = http.createServer(async (req, res) => {
583 585 // --- API authentifiée ---
584 586 if (p.startsWith('/api/')) {
585 587 if (!getSession(req)) return json(res, 401, { error: 'auth' });
586 if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '3.0.0' });
588 + if (p === '/api/me') {
589 + const r = getRegistry();
590 + return json(res, 200, { ok: true, host: os.hostname(), node: selfNode(), version: '3.2.0',
591 + 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 } });
592 + }
587 593 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });
594 + // --- registre mld (topologie vivante) ---
595 + if (p === '/api/registry' && req.method === 'GET') return json(res, 200, getRegistry());
596 + if (p === '/api/registry/refresh' && req.method === 'POST') {
597 + audit({ kind: 'registry_refresh', ip: clientIp(req) });
598 + const r = await refreshRegistry('manual');
599 + return json(res, r.ok ? 200 : 502, { ...r, registry: getRegistry() });
600 + }
601 + if (p === '/api/registry/tooling' && req.method === 'POST') {
602 + audit({ kind: 'registry_tooling', ip: clientIp(req) });
603 + return json(res, 200, { nodes: await ensureAllNodesTooling({ force: true }) });
604 + }
588 605 if (p === '/api/chats' && req.method === 'GET') {
589 606 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);
590 607 return json(res, 200, { chats: list });
@@ -612,7 +629,7 @@ const server = http.createServer(async (req, res) => {
612 629 id: crypto.randomBytes(6).toString('hex'),
613 630 title: (String(body.title || '').trim() || text.slice(0, 48)),
614 631 text,
615 project: findProject(body.project) ? body.project : null,
632 + project: resolveProject(body.project) ? body.project : null,
616 633 fav: !!body.fav, uses: 0,
617 634 created: Date.now(), updated: Date.now(),
618 635 };
@@ -627,7 +644,7 @@ const server = http.createServer(async (req, res) => {
627 644 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
628 645 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();
629 646 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);
630 if ('project' in body) pr.project = findProject(body.project) ? body.project : null;
647 + if ('project' in body) pr.project = resolveProject(body.project) ? body.project : null;
631 648 if (typeof body.fav === 'boolean') pr.fav = body.fav;
632 649 if (body.bumpUse) pr.uses = (pr.uses || 0) + 1;
633 650 pr.updated = Date.now(); savePrompts();
@@ -644,7 +661,7 @@ const server = http.createServer(async (req, res) => {
644 661 const raw = String(body.prompt || '').trim();
645 662 if (!raw) return json(res, 400, { error: 'prompt vide' });
646 663 try {
647 const project = findProject(body.project);
664 + const project = resolveProject(body.project);
648 665 const upgraded = await upgradePrompt(raw, project);
649 666 audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length });
650 667 return json(res, 200, { upgraded });
@@ -652,10 +669,10 @@ const server = http.createServer(async (req, res) => {
652 669 return json(res, 502, { error: e.message });
653 670 }
654 671 }
655 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES));
672 + if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(getSites()));
656 673 if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30));
657 674 if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7));
658 if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(SITES));
675 + if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(getSites()));
659 676 if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies());
660 677 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') }));
661 678 if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5));
@@ -677,7 +694,7 @@ const server = http.createServer(async (req, res) => {
677 694 if (p === '/api/admin/action' && req.method === 'POST') {
678 695 let body;
679 696 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
680 const site = SITES.find(s => s.app === body.app);
697 + const site = getSites().find(s => s.app === body.app);
681 698 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;
682 699 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });
683 700 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });
@@ -696,7 +713,7 @@ const server = http.createServer(async (req, res) => {
696 713 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });
697 714 }
698 715 if (p.startsWith('/api/admin/logs/')) {
699 const site = SITES.find(s => s.app === p.split('/')[4]);
716 + const site = getSites().find(s => s.app === p.split('/')[4]);
700 717 if (!site) return json(res, 404, { error: 'site inconnu' });
701 718 const out = site.log
702 719 ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`)
@@ -704,9 +721,9 @@ const server = http.createServer(async (req, res) => {
704 721 return json(res, 200, { logs: out.stdout || out.error || '(vide)' });
705 722 }
706 723 if (p.startsWith('/api/admin/commits/')) {
707 const site = SITES.find(s => s.app === p.split('/')[4]);
724 + const site = getSites().find(s => s.app === p.split('/')[4]);
708 725 if (!site) return json(res, 404, { error: 'site inconnu' });
709 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 ✓")`);
726 + 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 ✓")`);
710 727 return json(res, 200, { text: out.stdout || out.error || '(vide)' });
711 728 }
712 729 // --- Social : publication automatique + a la demande ---
@@ -882,7 +899,8 @@ function handleWsMessage(ws, msg) {
882 899 if (!prompt) return;
883 900 let chat = msg.chatId ? getChat(msg.chatId) : null;
884 901 if (!chat) {
885 const proj = findProject(msg.project) || PROJECTS[0];
902 + const proj = resolveProject(msg.project) || getProjects()[0];
903 + if (!proj) { ws.send(JSON.stringify({ type: 'error', message: 'Aucun projet connu : le registre mld est indisponible (voir Réglages → Registre).' })); return; }
886 904 chat = {
887 905 id: crypto.randomBytes(8).toString('hex'),
888 906 title: prompt.slice(0, 60),
@@ -914,7 +932,8 @@ function handleWsMessage(ws, msg) {
914 932 const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default';
915 933 const created = [];
916 934 const stamp = new Date().toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
917 for (const proj of PROJECTS) {
935 + const projectsNow = getProjects();
936 + for (const proj of projectsNow) {
918 937 const chat = {
919 938 id: crypto.randomBytes(8).toString('hex'),
920 939 title: '🌐 ' + prompt.slice(0, 46),
@@ -933,7 +952,7 @@ function handleWsMessage(ws, msg) {
933 952 }
934 953 saveChats();
935 954 audit({ kind: 'start_all', count: created.length, model, permMode, prompt: prompt.slice(0, 500) });
936 ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: PROJECTS.length }));
955 + ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: projectsNow.length }));
937 956 return;
938 957 }
939 958 if (msg.type === 'set_opts') {
@@ -991,6 +1010,21 @@ function handleWsMessage(ws, msg) {
991 1010 if (msg.type === 'eco_unsub') { ws.eco = false; return; }
992 1011 }
993 1012
1013 +// ---------- registre mld (topologie vivante) — AVANT le monitoring, qui en dépend ----------
1014 +await initRegistry({
1015 + dataDir: DATA_DIR,
1016 + orchestratorDocPath: path.join(ORCH_DIR, 'CLAUDE.md'),
1017 + onChange: (events) => {
1018 + for (const ev of events) {
1019 + audit({ kind: 'registry_' + ev.kind, app: ev.app || null, from: ev.from || null, to: ev.to || null });
1020 + // alerte visible partout (bandeau) : une app a bougé, la console suit
1021 + broadcastAll({ type: 'eco_alert', kind: 'registry', site: ev.app || 'admin-ka', reason: describeRegistryEvent(ev), ts: Date.now() });
1022 + }
1023 + // les conversations rattachées à une app déplacée seront réalignées à leur prochain tour (realignChat)
1024 + broadcastAll({ type: 'projects', projects: listProjects() });
1025 + },
1026 +});
1027 +
994 1028 // ---------- monitoring Écosystème ----------
995 1029 startMonitor({
996 1030 dataDir: DATA_DIR,
997 1031