@imenam/mcp-messenger 1.0.19 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -401,7 +401,12 @@ correspondants et l'état de lecture :
401
401
  identiques sont départagées par l'adresse pour que l'ordre ne bouge pas d'un
402
402
  rafraîchissement à l'autre ;
403
403
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
404
- nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
404
+ nom, ses correspondants, son dernier message et sa priorité la plus haute. Les deux
405
+ correspondants y sont d'abord **deux pastilles côte à côte**, séparées et non superposées
406
+ — leurs initiales doivent rester lisibles —, de la couleur que leur adresse leur donne
407
+ partout ailleurs : on voit qui parle avant de lire. Au survol, la
408
+ pastille dit d'où la boîte écrit, ce dont la session s'occupe, son adresse, et si c'est
409
+ elle qui a écrit en dernier. Trois puces
405
410
  la filtrent : tous, non lus, priorité haute ;
406
411
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
407
412
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
@@ -459,7 +464,13 @@ chose :**
459
464
  touchées depuis. **Une boîte créée après cette date est épargnée, même vide** — une
460
465
  session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon.
461
466
 
462
- L'un comme l'autre se font en deux temps, parce qu'ils ne se rattrapent pas :
467
+ S'y ajoute la suppression d'**une boîte en particulier** : chaque ligne de la colonne de
468
+ gauche porte une corbeille, révélée au survol ou au clavier. Elle n'efface rien d'elle-même,
469
+ elle ouvre la même fenêtre sur cette seule boîte, avec ce qu'elle contient sous les yeux.
470
+ C'est la seule des trois qui ne demande aucun critère : l'adresse vient d'être montrée du
471
+ doigt, il n'y a rien à revérifier.
472
+
473
+ Toutes se font en deux temps, parce qu'elles ne se rattrapent pas :
463
474
 
464
475
  1. **L'aperçu** liste les boîtes concernées, chacune avec son nom, son adresse, son nombre
465
476
  de messages et sa dernière activité. Chaque ligne se décoche.
@@ -545,6 +556,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
545
556
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
546
557
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
547
558
  | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus `before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
559
+ | `DELETE` | `/admin/mailboxes/:id` | Supprime une boîte désignée, quel que soit son état — 404 si l'adresse est inconnue |
548
560
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
549
561
 
550
562
  ---
@@ -568,8 +580,8 @@ la console (cookie), qui elle n'ouvre rien d'autre.
568
580
  rendu en markdown par construction de nœuds, jamais par assignation de balisage, et la
569
581
  CSP interdit tout script tiers.
570
582
  - La console ne peut ni écrire ni modifier un message. Ses seules commandes destructrices
571
- sont les deux nettoyages de boîtes, réservés au jeton d'administration, confirmés deux
572
- fois et revérifiés par le serveur avant exécution.
583
+ suppriment des boîtes : réservées au jeton d'administration, confirmées deux fois, et
584
+ revérifiées par le serveur quand elles portent sur un lot.
573
585
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
574
586
  agents via `MESSENGER_SERVER_URL`.
575
587
 
@@ -610,9 +622,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
610
622
 
611
623
  ### Tests
612
624
 
613
- 253 tests couvrant : les deux nettoyages de boîtes commandés depuis la console — vides,
614
- ou inactives depuis une date — avec leur aperçu, leur double confirmation et le refus
615
- nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
625
+ 266 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
626
+ désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
627
+ leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
616
628
  l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
617
629
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
618
630
  ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
@@ -264,6 +264,23 @@ export function createApp({ store, config }) {
264
264
  logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, ${reason}`);
265
265
  res.json({ before: input.before ?? null, empty: input.empty ?? false, ...removed });
266
266
  });
267
+ /**
268
+ * Suppression d'une boîte désignée à la main, quel que soit son état.
269
+ *
270
+ * Pas de critère à revérifier ici, et c'est voulu : les deux nettoyages en lot se
271
+ * protègent d'une liste qui aurait vieilli entre l'aperçu et la confirmation, alors que
272
+ * celle-ci porte sur une boîte que l'on vient de montrer du doigt. La boîte doit
273
+ * exister — supprimer une adresse inconnue serait un geste sans effet présenté comme un
274
+ * succès.
275
+ */
276
+ admin.delete("/mailboxes/:id", (req, res) => {
277
+ const id = normalizeAgentId(String(req.params.id));
278
+ if (!store.getAgent(id))
279
+ throw new MessengerError(`mailbox not found: ${id}`, 404);
280
+ const removed = store.deleteMailboxes([id]);
281
+ logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
282
+ res.json({ mailbox: id, ...removed });
283
+ });
267
284
  app.use("/admin", admin);
268
285
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
269
286
  // contrôle par route (identité de l'agent) vient ensuite.
@@ -1,7 +1,7 @@
1
- // Console de supervision : les routes /admin du serveur, en lecture — à deux exceptions
2
- // près, les nettoyages de boîtes, seules commandes de cette page qui écrivent, et qui
3
- // détruisent. Elles sont donc tenues à part : un aperçu, puis une confirmation, et rien
4
- // ne part sans le critère que le serveur revérifiera.
1
+ // Console de supervision : les routes /admin du serveur, en lecture — à trois exceptions
2
+ // près, les suppressions de boîtes, seules commandes de cette page qui écrivent, et qui
3
+ // détruisent. Elles passent toutes par la même fenêtre : un aperçu de ce qui va partir,
4
+ // puis une confirmation en deux clics.
5
5
  //
6
6
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
7
7
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
@@ -26,6 +26,7 @@ const ICONS = {
26
26
  close: ["M18 6 6 18", "m6 6 12 12"],
27
27
  link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
+ trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
29
30
  };
30
31
 
31
32
  const state = {
@@ -284,13 +285,22 @@ async function api(path, params) {
284
285
  return response.json();
285
286
  }
286
287
 
287
- /** Envoi d'une action à la console. La seule qui existe supprime : elle passe par ici. */
288
+ /** Envoi d'une action à la console. Les seules qui existent suppriment : elles passent par ici. */
288
289
  async function post(path, body) {
290
+ return action("POST", path, body);
291
+ }
292
+
293
+ /** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
294
+ async function remove(path) {
295
+ return action("DELETE", path, null);
296
+ }
297
+
298
+ async function action(method, path, body) {
289
299
  const response = await fetch(path, {
290
- method: "POST",
300
+ method,
291
301
  headers: { "content-type": "application/json", accept: "application/json" },
292
302
  credentials: "same-origin",
293
- body: JSON.stringify(body),
303
+ body: body === null ? undefined : JSON.stringify(body),
294
304
  });
295
305
  if (response.status === 401) {
296
306
  setLoggedIn(false);
@@ -603,10 +613,37 @@ function boxRow(box) {
603
613
  if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
604
614
  row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
605
615
  row.addEventListener("click", () => select("box", box.id));
606
- item.appendChild(row);
616
+
617
+ // La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
618
+ // un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
619
+ const line = node("div", "box-line");
620
+ line.appendChild(row);
621
+ line.appendChild(deleteBoxButton(box));
622
+ item.appendChild(line);
607
623
  return item;
608
624
  }
609
625
 
626
+ /**
627
+ * Suppression d'une boîte, à la main.
628
+ *
629
+ * Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
630
+ * ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
631
+ * lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
632
+ */
633
+ function deleteBoxButton(box) {
634
+ const label = `Supprimer la boîte ${boxLabel(box)}`;
635
+ const remove = node("button", "box-delete");
636
+ remove.type = "button";
637
+ remove.title = label;
638
+ remove.setAttribute("aria-label", label);
639
+ remove.appendChild(icon("trash"));
640
+ remove.addEventListener("click", (event) => {
641
+ event.stopPropagation();
642
+ openPurgeFor(box);
643
+ });
644
+ return remove;
645
+ }
646
+
610
647
  /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
611
648
  function currentThreadCount() {
612
649
  return state.threadCount || 0;
@@ -672,7 +709,7 @@ function threadRow(thread) {
672
709
  top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
673
710
  row.appendChild(top);
674
711
 
675
- row.appendChild(node("div", "thread-who", thread.participants.map((id) => party(id).name).join(" · ")));
712
+ row.appendChild(threadParticipants(thread));
676
713
 
677
714
  const bottom = node("div", "thread-bottom");
678
715
  bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
@@ -686,6 +723,38 @@ function threadRow(thread) {
686
723
  return item;
687
724
  }
688
725
 
726
+ /**
727
+ * Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
728
+ *
729
+ * Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
730
+ * d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
731
+ * écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
732
+ * projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
733
+ * qui a écrit en dernier.
734
+ */
735
+ function threadParticipants(thread) {
736
+ const who = node("div", "thread-who");
737
+
738
+ const stack = node("div", "avatar-stack");
739
+ for (const id of thread.participants) {
740
+ const person = party(id);
741
+ const badge = avatar(id, "avatar--sm avatar--stack");
742
+ badge.title = [
743
+ person.agentName || "agent sans nom",
744
+ person.box || (person.registered ? "boîte sans nom" : "boîte jamais connectée"),
745
+ id,
746
+ id === thread.last_message_from ? "a écrit le dernier message" : null,
747
+ ]
748
+ .filter(Boolean)
749
+ .join("\n");
750
+ stack.appendChild(badge);
751
+ }
752
+ who.appendChild(stack);
753
+
754
+ who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
755
+ return who;
756
+ }
757
+
689
758
  // --- Lecteur du fil --------------------------------------------------------
690
759
 
691
760
  function renderEmptyReader() {
@@ -1100,6 +1169,29 @@ function openPurge() {
1100
1169
  el.purge.showModal();
1101
1170
  }
1102
1171
 
1172
+ /**
1173
+ * Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
1174
+ *
1175
+ * Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
1176
+ * la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
1177
+ * forme et la même confirmation que les nettoyages en lot.
1178
+ */
1179
+ function openPurgeFor(box) {
1180
+ openPurge();
1181
+ purge.mode = "one";
1182
+ purge.mailboxes = [
1183
+ {
1184
+ id: box.id,
1185
+ agent_name: box.agent_name,
1186
+ display_name: box.display_name,
1187
+ last_activity_at: box.last_activity_at,
1188
+ messages: box.sent + box.received,
1189
+ },
1190
+ ];
1191
+ purge.selected = new Set([box.id]);
1192
+ renderPurgePreview();
1193
+ }
1194
+
1103
1195
  /** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
1104
1196
  function resetPurge() {
1105
1197
  purge.mode = null;
@@ -1141,19 +1233,28 @@ async function previewPurge(mode, load, before = null) {
1141
1233
  function renderPurgePreview() {
1142
1234
  const container = document.createDocumentFragment();
1143
1235
  const count = purge.mailboxes.length;
1144
- // Le critère est rappelé sous les yeux de qui va confirmer : les deux nettoyages
1145
- // remplissent la même liste, et ils ne suppriment pas les mêmes boîtes.
1146
- const since = purge.mode === "empty" ? "" : ` sans activité depuis le ${formatDay(purge.before)}`;
1147
- const found =
1148
- purge.mode === "empty" ? plural(count, "boîte vide", "boîtes vides") : `${plural(count, "boîte")}${since}`;
1236
+ // Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
1237
+ // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
1238
+ const since = ` sans activité depuis le ${formatDay(purge.before)}`;
1239
+ const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
1149
1240
 
1150
1241
  if (count === 0) {
1151
- container.appendChild(node("p", "purge-empty", `Aucune boîte ${purge.mode === "empty" ? "vide" : since.trim()}.`));
1242
+ container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
1152
1243
  el.purgeResult.replaceChildren(container);
1153
1244
  return;
1154
1245
  }
1155
1246
 
1156
- container.appendChild(node("p", "purge-count", `${found}.`));
1247
+ container.appendChild(
1248
+ node(
1249
+ "p",
1250
+ "purge-count",
1251
+ purge.mode === "one"
1252
+ ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
1253
+ : purge.mode === "empty"
1254
+ ? `${plural(count, "boîte vide", "boîtes vides")}.`
1255
+ : `${plural(count, "boîte")}${since}.`
1256
+ )
1257
+ );
1157
1258
 
1158
1259
  const list = node("ul", "purge-list");
1159
1260
  for (const mailbox of purge.mailboxes) {
@@ -1207,12 +1308,18 @@ async function confirmPurge() {
1207
1308
  }
1208
1309
 
1209
1310
  el.purgeConfirm.disabled = true;
1311
+ const deleted = [...purge.selected];
1210
1312
  try {
1211
- const removed = await post("/admin/mailboxes/purge", {
1212
- // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1213
- ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1214
- mailboxes: [...purge.selected],
1215
- });
1313
+ // Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
1314
+ // revérifier, l'utilisateur vient de la montrer du doigt.
1315
+ const removed =
1316
+ purge.mode === "one"
1317
+ ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
1318
+ : await post("/admin/mailboxes/purge", {
1319
+ // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1320
+ ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1321
+ mailboxes: deleted,
1322
+ });
1216
1323
  resetPurge();
1217
1324
  el.purgeResult.replaceChildren(
1218
1325
  node(
@@ -1221,7 +1328,11 @@ async function confirmPurge() {
1221
1328
  `${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
1222
1329
  )
1223
1330
  );
1224
- // La console montre encore ce qui n'existe plus : elle se relit entièrement.
1331
+ // La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
1332
+ // filtre est relâché s'il portait sur une boîte qui vient de disparaître.
1333
+ if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
1334
+ state.selection = { kind: "all", key: null };
1335
+ }
1225
1336
  state.threadId = null;
1226
1337
  state.messageId = null;
1227
1338
  el.sheet.hidden = true;
@@ -769,6 +769,54 @@ body {
769
769
  padding: 7px 10px;
770
770
  }
771
771
 
772
+ /* La ligne d'une boîte et son bouton de suppression, côte à côte. */
773
+ .box-line {
774
+ display: flex;
775
+ align-items: center;
776
+ gap: 2px;
777
+ }
778
+
779
+ .box-line .box {
780
+ flex: 1;
781
+ min-width: 0;
782
+ }
783
+
784
+ /*
785
+ Le bouton ne se montre qu'au survol ou au clavier : une corbeille par ligne, affichée en
786
+ permanence, transformerait la colonne en champ de mines. Il reste dans le flux — révélé
787
+ par l'opacité et non par `display` — pour que la ligne ne change pas de largeur.
788
+ */
789
+ .box-delete {
790
+ display: flex;
791
+ padding: 5px;
792
+ border: 0;
793
+ border-radius: var(--radius);
794
+ background: none;
795
+ color: var(--muted);
796
+ cursor: pointer;
797
+ opacity: 0;
798
+ }
799
+
800
+ .box-line:hover .box-delete,
801
+ .box-delete:focus-visible {
802
+ opacity: 1;
803
+ }
804
+
805
+ .box-delete:hover {
806
+ background: var(--danger-soft);
807
+ color: var(--danger);
808
+ }
809
+
810
+ .box-delete:focus-visible {
811
+ outline: 2px solid var(--accent);
812
+ outline-offset: -2px;
813
+ }
814
+
815
+ /* Colonne repliée : il n'y a plus de boîtes affichées, donc rien à supprimer. */
816
+ .is-collapsed .box-delete {
817
+ display: none;
818
+ }
819
+
772
820
  .box .agent-name {
773
821
  font-weight: 500;
774
822
  font-size: 13px;
@@ -1100,15 +1148,48 @@ body {
1100
1148
  color: var(--accent);
1101
1149
  }
1102
1150
 
1151
+ /* Les pastilles des deux correspondants, puis leurs noms : on voit qui parle avant de lire. */
1103
1152
  .thread-who {
1153
+ display: flex;
1154
+ align-items: center;
1155
+ gap: 8px;
1156
+ margin-top: 4px;
1157
+ min-width: 0;
1158
+ }
1159
+
1160
+ .thread-names {
1104
1161
  font-size: 12px;
1105
1162
  color: var(--muted);
1106
- margin-top: 2px;
1107
1163
  white-space: nowrap;
1108
1164
  overflow: hidden;
1109
1165
  text-overflow: ellipsis;
1110
1166
  }
1111
1167
 
1168
+ /* Dans une liste, la pastille sert de repère, pas de portrait : elle est plus petite. */
1169
+ .thread-who .avatar--sm {
1170
+ width: 22px;
1171
+ height: 22px;
1172
+ font-size: 10px;
1173
+ }
1174
+
1175
+ /*
1176
+ Ici les pastilles ne se chevauchent pas. Le chevauchement du lecteur dit « ces gens vont
1177
+ ensemble » quand la place manque ; dans une liste qui se survole, il mange les initiales,
1178
+ et ce sont justement elles qui identifient la boîte. L'anneau qui détachait les pastilles
1179
+ superposées n'a alors plus rien à détacher.
1180
+ */
1181
+ .thread-who .avatar-stack {
1182
+ gap: 4px;
1183
+ }
1184
+
1185
+ .thread-who .avatar-stack .avatar + .avatar {
1186
+ margin-left: 0;
1187
+ }
1188
+
1189
+ .thread-who .avatar--stack {
1190
+ box-shadow: none;
1191
+ }
1192
+
1112
1193
  .thread-bottom {
1113
1194
  display: flex;
1114
1195
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.19";
1
+ export declare const APP_VERSION = "1.0.20";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.19";
2
+ export const APP_VERSION = "1.0.20";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",