@imenam/mcp-messenger 1.0.15 → 1.0.17

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/.env.example CHANGED
@@ -60,3 +60,19 @@ MESSENGER_TOKEN=change-me
60
60
 
61
61
  # Délai maximal d'un appel HTTP vers le serveur (ms).
62
62
  MESSENGER_TIMEOUT_MS=15000
63
+
64
+ # Reprise de la boîte d'une session interrompue puis relancée.
65
+ # auto (défaut) : la session retrouve sa boîte quand c'est certain (l'hôte a redonné le
66
+ # même identifiant de session) ou sans ambiguïté (une seule boîte du projet n'est plus
67
+ # tenue par personne, et elle a servi).
68
+ # ask : rien n'est repris tout seul ; `whoami` présente les boîtes en attente, l'agent en
69
+ # rattache une avec `attach_to_mailbox`.
70
+ # off : chaque session repart d'une boîte neuve.
71
+ # Une boîte tenue par une session vivante n'est jamais reprise, quel que soit le réglage.
72
+ # MESSENGER_RESUME=auto
73
+
74
+ # Identifiant de la session hôte, celui qui relie une session reprise à celle qu'elle
75
+ # continue. Inutile à renseigner : il est lu dans l'environnement hérité de l'hôte
76
+ # (CLAUDE_CODE_SESSION_ID et équivalents). À poser soi-même pour des sessions lancées par
77
+ # script. Il n'est jamais écrit sur le disque en clair, seule son empreinte l'est.
78
+ # MESSENGER_SESSION_KEY=
package/README.md CHANGED
@@ -312,20 +312,61 @@ d'ouverture : rien n'est jamais illisible, mais rien n'est aussi parlant qu'un n
312
312
  Ces noms apparaissent dans `list_threads`, `list_messages`, `get_thread`, les
313
313
  notifications du veilleur et la console web.
314
314
 
315
- ### Reprendre le travail d'une session terminée
316
-
317
- Les boîtes survivent aux sessions. Après un redémarrage :
315
+ ### Reprendre le travail d'une session interrompue
316
+
317
+ Une session relancée est un nouveau processus. Sans mémoire, elle se ferait allouer une
318
+ boîte neuve et laisserait derrière elle ses fils, ses non-lus et **l'adresse que ses
319
+ correspondants connaissent** ; répété, cela multiplie les boîtes pour un travail unique.
320
+ La session retrouve donc la sienne toute seule, par un **registre local**.
321
+
322
+ Chaque session y inscrit sa boîte, avec l'empreinte de son projet et la preuve qu'elle
323
+ vit : le processus qui la tient et un battement de cœur. **Une boîte tenue par une session
324
+ vivante n'est jamais reprise** — c'est ce qui distingue ce mécanisme d'une adresse
325
+ partagée, et ce qui protège deux sessions ouvertes en parallèle sur le même projet. Une
326
+ boîte n'est reprenable qu'une fois **orpheline** : session close proprement, processus
327
+ mort, ou battement arrêté depuis plusieurs minutes.
328
+
329
+ Au démarrage, trois chemins, du plus certain au plus approximatif :
330
+
331
+ 1. **Le même identifiant de session.** L'hôte en donne un à sa session, et le processus MCP
332
+ en hérite. Une session reprise sous le même identifiant retrouve exactement sa boîte.
333
+ Rien n'est à configurer : la variable est lue dans l'environnement
334
+ (`CLAUDE_CODE_SESSION_ID` et équivalents), et `MESSENGER_SESSION_KEY` permet de la poser
335
+ soi-même pour des sessions lancées par script. Ni le projet ni cet identifiant ne sont
336
+ écrits en clair : le registre n'en garde que l'empreinte.
337
+ 2. **La seule boîte orpheline du projet, si elle a servi.** Sans identifiant, ou s'il a
338
+ changé, une boîte du même projet que plus personne ne tient est reprise quand elle est
339
+ la seule à l'être. Une boîte vide ne l'est jamais : il n'y a rien à y reprendre.
340
+ 3. **Le choix de l'agent.** Plusieurs orphelines, ou une reprise incertaine : rien n'est
341
+ pris en silence. `whoami` les liste avec leur nom, leur nombre de messages et leurs
342
+ non-lus, et l'agent rattache la bonne avec `attach_to_mailbox`. Les instructions du
343
+ serveur MCP lui disent aussi de rattacher une adresse `box_…` qu'il reconnaîtrait dans
344
+ son propre contexte : une session reprise le reçoit en entier.
345
+
346
+ `MESSENGER_RESUME` tranche pour ceux que la reprise automatique gêne : `auto` par défaut,
347
+ `ask` pour que tout passe par l'agent, `off` pour repartir chaque fois d'une boîte neuve.
348
+
349
+ Le journal du démarrage dit lequel des trois chemins a servi, et quelle variable a fourni
350
+ l'identifiant de session — c'est là qu'on vérifie ce que l'hôte transmet réellement.
351
+
352
+ Le rattachement manuel reste disponible en toutes circonstances :
318
353
 
319
354
  > « Reprends le travail de la session précédente, sa boîte est `box_9f2c4a01b3d7e5f8`. »
320
355
 
321
356
  L'agent appelle `attach_to_mailbox` avec cette adresse : il lit alors le courrier de cette
322
357
  boîte, y compris les messages arrivés pendant l'interruption, et écrit sous cette adresse.
323
358
  `attach_to_mailbox` sans argument le ramène à sa propre boîte. Plusieurs sessions peuvent
324
- être attachées à la même boîte — c'est volontaire, et c'est ce qui permet la reprise.
359
+ être attachées à la même boîte — c'est volontaire, et c'est ce qui permet la reprise. La
360
+ boîte rattachée devient celle que la session travaille : c'est elle que sa prochaine
361
+ reprise retrouvera.
325
362
 
326
363
  L'adresse est vérifiée avant le basculement : une faute de frappe renvoie
327
364
  `mailbox not found` au lieu de créer silencieusement une boîte vide.
328
365
 
366
+ Le registre est un fichier par boîte, en 0600, sous `~/.mcp-messenger/mailboxes/`
367
+ (`MESSENGER_STATE_DIR` pour le déplacer). Il est purement local : le serveur n'en sait
368
+ rien, et une entrée qu'aucune session n'a touchée depuis un mois est oubliée.
369
+
329
370
  ### Adresse fixe (cas particulier)
330
371
 
331
372
  Renseigner `MESSENGER_AGENT_ID` fige l'adresse : toutes les sessions utilisant cette
@@ -362,7 +403,14 @@ correspondants et l'état de lecture :
362
403
  la filtrent : tous, non lus, priorité haute ;
363
404
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
364
405
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
365
- l'a lu ;
406
+ l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
407
+ citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
408
+ agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
409
+ rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
410
+ écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
411
+ suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
412
+ Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
413
+ ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
366
414
  - **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
367
415
  fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
368
416
  dossier, état). Tout ce qui se recopie a son bouton de copie ;
@@ -508,8 +556,10 @@ src/
508
556
  server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
509
557
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
510
558
  server/ui/ # console web (HTML/CSS/JS sans dépendance)
559
+ server/ui/markdown.js # rendu markdown du corps des messages, en nœuds DOM
511
560
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
512
561
  cli/watch-handoff.ts # relais 0600 entre la session MCP et son veilleur
562
+ mcp/mailbox-registry.ts # registre local des boîtes : reprise d'une session interrompue
513
563
  client/ # client HTTP utilisé par le mode MCP
514
564
  mcp/ # définition des outils et serveur MCP stdio
515
565
  test/ # store, config, API HTTP, outils MCP, binaire réel
@@ -517,7 +567,10 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
517
567
 
518
568
  ### Tests
519
569
 
520
- 112 tests couvrant : le stockage et l'isolation des boîtes, la validation de configuration,
570
+ 207 tests couvrant : la reprise de la boîte d'une session interrompue (clé de session de
571
+ 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
572
+ l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
573
+ ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
521
574
  l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
522
575
  agent, jeton de console distinct), les outils MCP de bout en bout (deux agents qui
523
576
  conversent à travers un vrai serveur), l'allocation des boîtes par session — deux processus
@@ -47,6 +47,7 @@ const OPTIONAL_VARS = [
47
47
  ["MESSENGER_AGENT_NAME", "nom de l'agent (le projet, typiquement) ; distinct du nom de la boîte, que l'agent pose lui-même"],
48
48
  ["MESSENGER_AGENT_DESCRIPTION", "à quoi sert cet agent, pour ses correspondants"],
49
49
  ["MESSENGER_TIMEOUT_MS", "délai maximal d'un appel HTTP (défaut : 15000)"],
50
+ ["MESSENGER_RESUME", "reprise de la boîte d'une session interrompue : auto (défaut), ask, off"],
50
51
  ];
51
52
  const CLAUDE_MD_MARKER = "<!-- mcp-messenger -->";
52
53
  const CLAUDE_MD_BLOCK = `${CLAUDE_MD_MARKER}
@@ -47,6 +47,11 @@ export function resolveWatchConfig(mailbox, env = process.env) {
47
47
  agentName: null,
48
48
  agentDescription: null,
49
49
  timeoutMs: Number(env.MESSENGER_TIMEOUT_MS) || 15_000,
50
+ // Le veilleur écoute une adresse qu'on lui donne : il n'alloue rien et n'a donc
51
+ // aucune boîte à reprendre.
52
+ sessionKey: null,
53
+ sessionKeySource: null,
54
+ resume: "off",
50
55
  },
51
56
  };
52
57
  }
@@ -22,6 +22,16 @@ export interface ServerConfig {
22
22
  /** Plafond d'attente d'un `wait_for_message` (secondes). */
23
23
  maxWaitSeconds: number;
24
24
  }
25
+ /**
26
+ * Ce qu'une session fait des boîtes laissées par une session précédente du même projet.
27
+ *
28
+ * `auto` : reprise sans rien demander quand elle est certaine (clé de session identique)
29
+ * ou sans ambiguïté (une seule boîte orpheline, et elle a servi).
30
+ * `ask` : rien n'est repris tout seul, les candidates sont présentées à l'agent.
31
+ * `off` : aucune recherche, chaque session repart d'une boîte neuve.
32
+ */
33
+ export declare const RESUME_MODES: readonly ["auto", "ask", "off"];
34
+ export type ResumeMode = (typeof RESUME_MODES)[number];
25
35
  export interface AgentConfig {
26
36
  /** null = une boîte propre à cette session est allouée au démarrage. */
27
37
  agentId: string | null;
@@ -31,7 +41,28 @@ export interface AgentConfig {
31
41
  agentName: string | null;
32
42
  agentDescription: string | null;
33
43
  timeoutMs: number;
44
+ /**
45
+ * Identifiant de la session hôte, s'il est lisible. C'est lui qui relie une session
46
+ * reprise à celle qu'elle continue ; il n'est jamais écrit sur le disque en clair.
47
+ */
48
+ sessionKey: string | null;
49
+ /** D'où vient cette clé, pour que le journal puisse le dire. */
50
+ sessionKeySource: string | null;
51
+ resume: ResumeMode;
34
52
  }
53
+ /**
54
+ * Variables où chercher l'identifiant de la session hôte, dans l'ordre.
55
+ *
56
+ * Rien n'a besoin d'être configuré : le processus MCP est un enfant de l'hôte et hérite
57
+ * de son environnement. `MESSENGER_SESSION_KEY` vient en tête pour qui lance ses sessions
58
+ * autrement et veut poser la clé lui-même.
59
+ */
60
+ export declare const SESSION_KEY_VARS: readonly ["MESSENGER_SESSION_KEY", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID", "MCP_SESSION_ID"];
61
+ /** Première clé de session lisible dans l'environnement, avec la variable qui l'a donnée. */
62
+ export declare function readSessionKey(env?: NodeJS.ProcessEnv): {
63
+ key: string;
64
+ source: string;
65
+ } | null;
35
66
  export declare function isLoopbackHost(host: string): boolean;
36
67
  export declare function loadServerConfig(env?: NodeJS.ProcessEnv): ServerConfig;
37
68
  export declare function loadAgentConfig(env?: NodeJS.ProcessEnv): AgentConfig;
@@ -9,6 +9,38 @@ export class ConfigError extends Error {
9
9
  }
10
10
  /** Le serveur n'écoute jamais ailleurs : la boîte aux lettres reste locale à la machine. */
11
11
  export const SERVER_HOST = "127.0.0.1";
12
+ /**
13
+ * Ce qu'une session fait des boîtes laissées par une session précédente du même projet.
14
+ *
15
+ * `auto` : reprise sans rien demander quand elle est certaine (clé de session identique)
16
+ * ou sans ambiguïté (une seule boîte orpheline, et elle a servi).
17
+ * `ask` : rien n'est repris tout seul, les candidates sont présentées à l'agent.
18
+ * `off` : aucune recherche, chaque session repart d'une boîte neuve.
19
+ */
20
+ export const RESUME_MODES = ["auto", "ask", "off"];
21
+ /**
22
+ * Variables où chercher l'identifiant de la session hôte, dans l'ordre.
23
+ *
24
+ * Rien n'a besoin d'être configuré : le processus MCP est un enfant de l'hôte et hérite
25
+ * de son environnement. `MESSENGER_SESSION_KEY` vient en tête pour qui lance ses sessions
26
+ * autrement et veut poser la clé lui-même.
27
+ */
28
+ export const SESSION_KEY_VARS = [
29
+ "MESSENGER_SESSION_KEY",
30
+ "CLAUDE_CODE_SESSION_ID",
31
+ "CLAUDE_SESSION_ID",
32
+ "CURSOR_SESSION_ID",
33
+ "MCP_SESSION_ID",
34
+ ];
35
+ /** Première clé de session lisible dans l'environnement, avec la variable qui l'a donnée. */
36
+ export function readSessionKey(env = process.env) {
37
+ for (const name of SESSION_KEY_VARS) {
38
+ const value = nonEmpty(env[name]);
39
+ if (value)
40
+ return { key: value, source: name };
41
+ }
42
+ return null;
43
+ }
12
44
  export function isLoopbackHost(host) {
13
45
  return LOOPBACK_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
14
46
  }
@@ -70,6 +102,11 @@ export function loadAgentConfig(env = process.env) {
70
102
  if (url.protocol !== "http:" && url.protocol !== "https:") {
71
103
  throw new ConfigError(`MESSENGER_SERVER_URL must use http or https (got ${url.protocol}).`);
72
104
  }
105
+ const resume = nonEmpty(env.MESSENGER_RESUME)?.toLowerCase() ?? "auto";
106
+ if (!RESUME_MODES.includes(resume)) {
107
+ throw new ConfigError(`MESSENGER_RESUME must be one of ${RESUME_MODES.join(", ")} (got "${resume}").`);
108
+ }
109
+ const session = readSessionKey(env);
73
110
  return {
74
111
  agentId,
75
112
  serverUrl: rawUrl.replace(/\/+$/, ""),
@@ -77,6 +114,9 @@ export function loadAgentConfig(env = process.env) {
77
114
  agentName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
115
  agentDescription: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
79
116
  timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
117
+ sessionKey: session?.key ?? null,
118
+ sessionKeySource: session?.source ?? null,
119
+ resume: resume,
80
120
  };
81
121
  }
82
122
  function parseAgentTokens(raw) {
@@ -848,9 +848,44 @@ function clamp(value, min, max) {
848
848
  return Math.min(max, Math.max(min, Math.trunc(value)));
849
849
  }
850
850
  function preview(body) {
851
- const flat = body.replace(/\s+/g, " ").trim();
851
+ const flat = stripMarkdown(body).replace(/\s+/g, " ").trim();
852
852
  return flat.length <= PREVIEW_LENGTH ? flat : `${flat.slice(0, PREVIEW_LENGTH - 1)}…`;
853
853
  }
854
+ /**
855
+ * Le corps d'un message est du markdown ; un aperçu, non — il tient sur une ligne, dans
856
+ * une liste, sans mise en forme pour le porter. La syntaxe y devient donc du bruit : on
857
+ * garde ce que le markdown dit, on jette ce qui sert à le mettre en forme.
858
+ *
859
+ * C'est un aplatissement, pas une analyse : l'aperçu n'a pas à rendre compte de la
860
+ * structure du message, seulement à en donner les premiers mots lisibles.
861
+ */
862
+ function stripMarkdown(body) {
863
+ return (body
864
+ // Un bloc de code n'a rien de lisible en 160 caractères : seule sa langue renseigne.
865
+ .replace(/^ {0,3}(```|~~~)[^\n]*\n[\s\S]*?(?:\n {0,3}\1[^\n]*|$)/gm, (block) => {
866
+ const language = /^ {0,3}(?:```|~~~)\s*([^\s`]+)/.exec(block)?.[1];
867
+ return language ? `[code ${language}]` : "[code]";
868
+ })
869
+ .replace(/`+([^`]+)`+/g, "$1")
870
+ // Images et liens : l'intitulé porte le sens, l'URL encombre.
871
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
872
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
873
+ .replace(/<((?:https?:\/\/|mailto:)[^\s>]+)>/g, "$1")
874
+ // Marqueurs de bloc en tête de ligne : titres, citations, puces, filets.
875
+ .replace(/^ {0,3}#{1,6}\s+/gm, "")
876
+ .replace(/^ {0,3}>[ \t]?/gm, "")
877
+ .replace(/^\s*([-*+]|\d{1,9}[.)])[ \t]+/gm, "· ")
878
+ .replace(/^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/gm, " ")
879
+ // Un tableau perd sa grille : ses cellules restent, séparées.
880
+ .replace(/^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/gm, " ")
881
+ .replace(/^[ \t]*\|/gm, "")
882
+ .replace(/\|[ \t]*$/gm, "")
883
+ .replace(/[ \t]*\|[ \t]*/g, " · ")
884
+ // Emphase : les marques tombent, le texte reste.
885
+ .replace(/(\*\*|__)(\S[\s\S]*?\S)\1/g, "$2")
886
+ .replace(/~~(\S[\s\S]*?\S)~~/g, "$1")
887
+ .replace(/(?<![\w*])([*_])(\S[^*_\n]*?\S)\1(?![\w*])/g, "$2"));
888
+ }
854
889
  /** Échappe les jokers SQL : la recherche reste littérale, `%` cherche bien un `%`. */
855
890
  function escapeLike(value) {
856
891
  return value.replace(/[\\%_]/g, (char) => `\\${char}`);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Registre local des boîtes tenues par les sessions MCP de cette machine.
3
+ *
4
+ * Une session reprise est un nouveau processus : sans mémoire, elle se ferait allouer une
5
+ * boîte neuve et perdrait ses fils, ses non-lus et son adresse — celle que ses
6
+ * correspondants connaissent. Le registre est cette mémoire, et il est purement local :
7
+ * le serveur n'en sait rien, rien ne part sur le réseau.
8
+ *
9
+ * Une entrée dit trois choses : quelle boîte, pour quel projet, et **tenue par qui**. Ce
10
+ * dernier point est ce qui rend la reprise sûre : tant que le processus inscrit vit et
11
+ * bat, sa boîte lui appartient et aucune autre session ne la prendra. Une boîte n'est
12
+ * reprenable qu'une fois **orpheline** : session close proprement, processus mort, ou
13
+ * battement arrêté depuis assez longtemps pour qu'un numéro de processus recyclé ne
14
+ * puisse pas faire illusion.
15
+ *
16
+ * Rien de lisible n'est écrit : le projet et la clé de session ne figurent que par leur
17
+ * empreinte, et les fichiers sont en 0600 comme les relais de veilleurs.
18
+ *
19
+ * Un processus ne tient qu'une entrée à la fois, celle de sa session : c'est vrai du mode
20
+ * MCP, où un processus est une session. Les tests qui simulent plusieurs sessions dans un
21
+ * même processus en tiennent compte.
22
+ */
23
+ export interface RegistryEntry {
24
+ mailbox: string;
25
+ /** Empreinte du projet : même répertoire, même agent, même serveur. */
26
+ fingerprint: string;
27
+ /** Empreinte de l'identifiant de session de l'hôte, ou null s'il n'y en avait pas. */
28
+ session_key: string | null;
29
+ /** Processus MCP qui tient la boîte. Sa mort rend l'entrée orpheline. */
30
+ pid: number;
31
+ started_at: string;
32
+ heartbeat_at: string;
33
+ /** Posé à l'arrêt propre de la session : la boîte est libre sans attendre. */
34
+ closed_at: string | null;
35
+ }
36
+ /** Rythme du battement de cœur d'une session vivante. */
37
+ export declare const HEARTBEAT_MS = 60000;
38
+ /** Répertoire du registre. `MESSENGER_STATE_DIR` le déplace, comme pour les relais. */
39
+ export declare function registryDir(): string;
40
+ /**
41
+ * Ce qui fait qu'une boîte appartient au même travail : le répertoire d'où la session est
42
+ * lancée, le nom d'agent de sa configuration et le serveur qu'elle utilise. Deux sessions
43
+ * du même projet partagent cette empreinte ; deux projets ne la partagent jamais.
44
+ */
45
+ export declare function projectFingerprint(input: {
46
+ cwd: string;
47
+ agentName: string | null;
48
+ serverUrl: string;
49
+ }): string;
50
+ /** L'identifiant de session de l'hôte n'est jamais écrit en clair : seule son empreinte l'est. */
51
+ export declare function sessionKeyDigest(key: string): string;
52
+ /** Toutes les entrées d'un projet, la plus récemment vivante en tête. */
53
+ export declare function listEntries(fingerprint: string): RegistryEntry[];
54
+ /** Vrai tant que le processus existe, même s'il appartient à un autre utilisateur. */
55
+ export declare function isProcessAlive(pid: number): boolean;
56
+ /** Une entrée est orpheline quand plus aucune session vivante ne la tient. */
57
+ export declare function isOrphan(entry: RegistryEntry, now?: number): boolean;
58
+ /** Boîtes du projet qu'aucune session vivante ne tient, la plus récente en tête. */
59
+ export declare function orphanEntries(fingerprint: string, now?: number): RegistryEntry[];
60
+ /** Inscrit la boîte de cette session, et se souvient de l'entrée pour la tenir à jour. */
61
+ export declare function claim(input: {
62
+ mailbox: string;
63
+ fingerprint: string;
64
+ sessionKey: string | null;
65
+ }): RegistryEntry;
66
+ /**
67
+ * Suit la session quand elle se rattache ailleurs (`attach_to_mailbox`) : c'est cette
68
+ * boîte-là qu'elle travaille, donc c'est elle que la prochaine reprise doit retrouver.
69
+ * L'entrée précédente est close, pas supprimée : la boîte qu'elle désigne reste reprenable.
70
+ */
71
+ export declare function rebind(mailbox: string): void;
72
+ /** Dit que la session est toujours là. Sans ce battement, sa boîte finit par être reprenable. */
73
+ export declare function heartbeat(): void;
74
+ /** Libère la boîte à l'arrêt de la session : la reprise n'a pas à attendre l'expiration. */
75
+ export declare function releaseOwn(): void;
76
+ /** Entrée tenue par ce processus, pour les tests et le journal. */
77
+ export declare function ownEntry(): RegistryEntry | null;
78
+ /** Oublie les entrées qu'aucune session n'a touchées depuis longtemps. */
79
+ export declare function purge(now?: number): number;
80
+ /** Remet le module à zéro entre deux tests : le processus, lui, ne tient qu'une entrée. */
81
+ export declare function resetOwnEntry(): void;
Binary file
@@ -0,0 +1,34 @@
1
+ import type { MessengerClient } from "../client/messenger-client.js";
2
+ /**
3
+ * Ce que le démarrage a trouvé des sessions précédentes, gardé pour `whoami`.
4
+ *
5
+ * La reprise se décide avant que l'agent ne parle : il faut donc pouvoir la lui dire
6
+ * plus tard, au premier outil qui parle d'identité. L'état est attaché au client plutôt
7
+ * qu'au module — deux sessions cohabitent dans un même processus pendant les tests.
8
+ */
9
+ export interface ResumeCandidate {
10
+ mailbox: string;
11
+ agent_name: string | null;
12
+ mailbox_name: string | null;
13
+ /** Messages présents dans la boîte, corbeille comprise : de quoi juger si elle a servi. */
14
+ messages: number;
15
+ unread: number;
16
+ last_activity: string | null;
17
+ }
18
+ export interface ResumeState {
19
+ /**
20
+ * Comment la boîte de cette session a été retrouvée :
21
+ * `session-key`, l'hôte a redonné le même identifiant de session — c'est une certitude ;
22
+ * `orphan`, une seule boîte du projet restait sans session pour la tenir ;
23
+ * null, la boîte est neuve.
24
+ */
25
+ resumed: "session-key" | "orphan" | null;
26
+ /** Boîtes laissées par une session arrêtée, que l'agent peut rattacher lui-même. */
27
+ candidates: ResumeCandidate[];
28
+ /** Variable d'environnement qui a fourni l'identifiant de session, s'il y en avait une. */
29
+ keySource: string | null;
30
+ }
31
+ export declare function setResumeState(client: MessengerClient, state: ResumeState): void;
32
+ export declare function getResumeState(client: MessengerClient): ResumeState | null;
33
+ /** Une boîte reprise n'est plus une boîte à proposer : l'agent y est déjà. */
34
+ export declare function forgetCandidate(client: MessengerClient, mailbox: string): void;
@@ -0,0 +1,14 @@
1
+ const states = new WeakMap();
2
+ export function setResumeState(client, state) {
3
+ states.set(client, state);
4
+ }
5
+ export function getResumeState(client) {
6
+ return states.get(client) ?? null;
7
+ }
8
+ /** Une boîte reprise n'est plus une boîte à proposer : l'agent y est déjà. */
9
+ export function forgetCandidate(client, mailbox) {
10
+ const state = states.get(client);
11
+ if (!state)
12
+ return;
13
+ states.set(client, { ...state, candidates: state.candidates.filter((c) => c.mailbox !== mailbox) });
14
+ }
@@ -1,16 +1,36 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { type AgentConfig } from "../config.js";
3
3
  import { MessengerClient } from "../client/messenger-client.js";
4
+ import { type ResumeCandidate } from "./resume-state.js";
4
5
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
5
6
  export declare function createMcpServer(client: MessengerClient): Server;
7
+ export interface Identity {
8
+ mailbox: string;
9
+ /** Non nul quand la boîte vient d'une session précédente. Voir `ResumeState`. */
10
+ resumed: "session-key" | "orphan" | null;
11
+ /** Boîtes orphelines du même projet qui n'ont pas été reprises d'office. */
12
+ candidates: ResumeCandidate[];
13
+ }
6
14
  /**
7
15
  * Donne son adresse à la session.
8
16
  *
9
- * Avec `MESSENGER_AGENT_ID`, l'adresse est fixe et partagée par toutes les sessions qui
10
- * utilisent cette configuration. Sans elle — le cas normal — une boîte neuve est allouée
11
- * pour cette session : deux agents lancés dans le même projet ne se marchent pas dessus.
12
- * L'identifiant est opaque : c'est une adresse à transmettre, pas un rôle à deviner.
17
+ * Trois chemins, du plus certain au plus approximatif :
18
+ *
19
+ * 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
20
+ * configuration. Rien à chercher.
21
+ * 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
22
+ * l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
23
+ * qu'aucune session vivante ne tient plus et qui a réellement servi.
24
+ * 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
25
+ * gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
26
+ * sienne dans son contexte.
27
+ *
28
+ * Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
29
+ * ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
30
+ * parallèle sur le même projet.
13
31
  */
14
- export declare function resolveIdentity(client: MessengerClient, config: AgentConfig): Promise<string>;
32
+ export declare function resolveIdentity(client: MessengerClient, config: AgentConfig, options?: {
33
+ cwd?: string;
34
+ }): Promise<Identity>;
15
35
  /** Point d'entrée du mode MCP (mode par défaut, sans argument). */
16
36
  export declare function runMcpMode(): Promise<void>;
@@ -7,6 +7,8 @@ import { removeOwnHandoffs } from "../cli/watch-handoff.js";
7
7
  import { newId } from "../core/ids.js";
8
8
  import { logger } from "../logger.js";
9
9
  import { APP_VERSION } from "../version.js";
10
+ import { HEARTBEAT_MS, claim, heartbeat, orphanEntries, projectFingerprint, purge, releaseOwn, sessionKeyDigest, } from "./mailbox-registry.js";
11
+ import { setResumeState } from "./resume-state.js";
10
12
  import { TOOLS, callTool } from "./tools.js";
11
13
  /**
12
14
  * Ce que l'hôte reçoit à la poignée de main, avant le moindre appel d'outil.
@@ -29,6 +31,11 @@ Who you are, and who the others are — read this before writing to anyone:
29
31
  - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
30
32
  - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
31
33
 
34
+ Resuming earlier work:
35
+ - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
+ - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
+ - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
+
32
39
  Writing:
33
40
  - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
34
41
  - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
@@ -55,17 +62,61 @@ export function createMcpServer(client) {
55
62
  /**
56
63
  * Donne son adresse à la session.
57
64
  *
58
- * Avec `MESSENGER_AGENT_ID`, l'adresse est fixe et partagée par toutes les sessions qui
59
- * utilisent cette configuration. Sans elle — le cas normal — une boîte neuve est allouée
60
- * pour cette session : deux agents lancés dans le même projet ne se marchent pas dessus.
61
- * L'identifiant est opaque : c'est une adresse à transmettre, pas un rôle à deviner.
65
+ * Trois chemins, du plus certain au plus approximatif :
66
+ *
67
+ * 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
68
+ * configuration. Rien à chercher.
69
+ * 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
70
+ * l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
71
+ * qu'aucune session vivante ne tient plus et qui a réellement servi.
72
+ * 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
73
+ * gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
74
+ * sienne dans son contexte.
75
+ *
76
+ * Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
77
+ * ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
78
+ * parallèle sur le même projet.
62
79
  */
63
- export async function resolveIdentity(client, config) {
80
+ export async function resolveIdentity(client, config, options = {}) {
81
+ const keySource = config.sessionKey ? config.sessionKeySource : null;
64
82
  if (config.agentId) {
65
83
  client.setAgentId(config.agentId, { own: true });
66
84
  await client.register();
67
- return config.agentId;
85
+ const identity = { mailbox: config.agentId, resumed: null, candidates: [] };
86
+ setResumeState(client, { resumed: null, candidates: [], keySource });
87
+ return identity;
88
+ }
89
+ const fingerprint = projectFingerprint({
90
+ cwd: options.cwd ?? process.cwd(),
91
+ agentName: config.agentName,
92
+ serverUrl: config.serverUrl,
93
+ });
94
+ const sessionKey = config.sessionKey ? sessionKeyDigest(config.sessionKey) : null;
95
+ purge();
96
+ const found = config.resume === "off" ? [] : await inspectOrphans(config, orphanEntries(fingerprint));
97
+ const exact = sessionKey ? found.find((c) => c.entry.session_key === sessionKey) : undefined;
98
+ // Une seule boîte laissée par une session arrêtée, et elle a servi : l'ambiguïté
99
+ // n'existe pas. Plusieurs, ou une boîte vide, et c'est à l'agent de choisir.
100
+ const sole = found.length === 1 && found[0].candidate.messages > 0 ? found[0] : undefined;
101
+ const taken = config.resume === "auto" ? (exact ?? sole) : exact;
102
+ if (taken) {
103
+ client.setAgentId(taken.candidate.mailbox, { own: true });
104
+ await client.register();
105
+ claim({ mailbox: taken.candidate.mailbox, fingerprint, sessionKey });
106
+ const resumed = taken === exact ? "session-key" : "orphan";
107
+ const candidates = found.filter((c) => c !== taken).map((c) => c.candidate);
108
+ setResumeState(client, { resumed, candidates, keySource });
109
+ return { mailbox: taken.candidate.mailbox, resumed, candidates };
68
110
  }
111
+ const mailbox = await allocateMailbox(client);
112
+ claim({ mailbox, fingerprint, sessionKey });
113
+ // Une boîte vide n'apprend rien à personne : seules celles qui ont du courrier valent
114
+ // la peine d'être proposées.
115
+ const candidates = found.map((c) => c.candidate).filter((c) => c.messages > 0);
116
+ setResumeState(client, { resumed: null, candidates, keySource });
117
+ return { mailbox, resumed: null, candidates };
118
+ }
119
+ async function allocateMailbox(client) {
69
120
  // `fresh` fait échouer l'enregistrement si l'adresse existe déjà : la collision, même
70
121
  // improbable sur 64 bits d'aléa, ne peut pas faire atterrir deux sessions au même endroit.
71
122
  for (let attempt = 0; attempt < 5; attempt += 1) {
@@ -83,6 +134,57 @@ export async function resolveIdentity(client, config) {
83
134
  }
84
135
  throw new Error("could not allocate a mailbox: too many id collisions");
85
136
  }
137
+ /**
138
+ * Ce que le journal dit du démarrage.
139
+ *
140
+ * C'est le seul endroit d'où l'on peut vérifier que l'hôte transmet bien un identifiant
141
+ * de session, et lequel : sans cette ligne, une reprise qui n'a pas lieu ne s'explique pas.
142
+ */
143
+ function describeIdentity(identity, config) {
144
+ const key = config.sessionKeySource ? `session key from ${config.sessionKeySource}` : "no host session key";
145
+ const how = identity.resumed === "session-key"
146
+ ? "resumed from the previous session (same host session id)"
147
+ : identity.resumed === "orphan"
148
+ ? "resumed the only mailbox left by a stopped session of this project"
149
+ : "new mailbox";
150
+ const waiting = identity.candidates.length > 0 ? `, ${identity.candidates.length} resumable mailbox(es)` : "";
151
+ return ` — ${how} (${key})${waiting}`;
152
+ }
153
+ /** Nombre de boîtes orphelines examinées : au-delà, le démarrage coûterait plus qu'il ne rend. */
154
+ const MAX_CANDIDATES = 5;
155
+ /**
156
+ * Confronte les entrées du registre au serveur : une boîte peut avoir disparu (base
157
+ * réinitialisée), et une boîte vide n'a rien à reprendre. Un serveur injoignable ne fait
158
+ * pas échouer le démarrage, il rend simplement la reprise impossible pour cette fois.
159
+ */
160
+ async function inspectOrphans(config, entries) {
161
+ // Client jetable : la session n'a pas encore d'adresse, et celle-ci ne doit pas bouger
162
+ // au gré de l'inspection.
163
+ const probe = new MessengerClient(config);
164
+ const found = [];
165
+ for (const entry of entries.slice(0, MAX_CANDIDATES)) {
166
+ probe.setAgentId(entry.mailbox);
167
+ try {
168
+ const agent = await probe.getAgent(entry.mailbox);
169
+ const stats = await probe.stats();
170
+ found.push({
171
+ entry,
172
+ candidate: {
173
+ mailbox: entry.mailbox,
174
+ agent_name: agent.agent_name,
175
+ mailbox_name: agent.display_name,
176
+ messages: stats.folders.reduce((total, folder) => total + folder.total, 0),
177
+ unread: stats.unread_total,
178
+ last_activity: agent.last_seen_at,
179
+ },
180
+ });
181
+ }
182
+ catch (error) {
183
+ logger.info(`mailbox ${entry.mailbox} cannot be resumed: ${error instanceof Error ? error.message : error}`);
184
+ }
185
+ }
186
+ return found;
187
+ }
86
188
  /** Point d'entrée du mode MCP (mode par défaut, sans argument). */
87
189
  export async function runMcpMode() {
88
190
  const config = loadAgentConfig();
@@ -90,18 +192,27 @@ export async function runMcpMode() {
90
192
  // Un serveur momentanément injoignable ne doit pas empêcher la session MCP de démarrer :
91
193
  // l'agent verra l'erreur au premier outil appelé, et pourra réessayer.
92
194
  try {
93
- const agentId = await resolveIdentity(client, config);
94
- logger.info(`mailbox \`${agentId}\` ready on ${config.serverUrl}`);
195
+ const identity = await resolveIdentity(client, config);
196
+ logger.info(`mailbox \`${identity.mailbox}\` ready on ${config.serverUrl}${describeIdentity(identity, config)}`);
95
197
  }
96
198
  catch (error) {
97
199
  logger.warn(`could not reach ${config.serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
98
200
  }
201
+ // Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
202
+ // `unref` : c'est une trace, pas une raison de garder le processus en vie.
203
+ setInterval(heartbeat, HEARTBEAT_MS).unref();
99
204
  // Le relais vers le veilleur porte le jeton de la session : il ne survit pas à la
100
205
  // session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
101
- process.on("exit", removeOwnHandoffs);
206
+ // L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
207
+ // c'est elle qui rendra sa boîte à la session suivante.
208
+ process.on("exit", () => {
209
+ removeOwnHandoffs();
210
+ releaseOwn();
211
+ });
102
212
  for (const signal of ["SIGINT", "SIGTERM"]) {
103
213
  process.on(signal, () => {
104
214
  removeOwnHandoffs();
215
+ releaseOwn();
105
216
  process.exit(0);
106
217
  });
107
218
  }
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { writeHandoff } from "../cli/watch-handoff.js";
3
3
  import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
4
+ import { rebind } from "./mailbox-registry.js";
5
+ import { forgetCandidate, getResumeState } from "./resume-state.js";
4
6
  /** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
5
7
  const PACKAGE_NAME = "@imenam/mcp-messenger";
6
8
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
@@ -335,6 +337,7 @@ export async function callTool(client, name, rawArgs) {
335
337
  if (client.isAttachedElsewhere) {
336
338
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
337
339
  }
340
+ lines.push(...resumeLines(client));
338
341
  lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
339
342
  // Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
340
343
  // Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
@@ -350,12 +353,17 @@ export async function callTool(client, name, rawArgs) {
350
353
  if (!own)
351
354
  throw new Error("this session has no mailbox of its own to return to");
352
355
  client.setAgentId(own);
356
+ rebind(own);
353
357
  return `Back to this session's own mailbox: ${own}`;
354
358
  }
355
359
  // Vérifie que la boîte existe avant de basculer : une faute de frappe créerait
356
360
  // silencieusement une boîte vide, et l'agent croirait avoir repris le travail.
357
361
  const target = await client.getAgent(input.mailbox_id);
358
362
  client.setAgentId(target.id);
363
+ // La session travaille désormais cette boîte : c'est elle que sa prochaine reprise
364
+ // doit retrouver, et elle n'a plus à être proposée.
365
+ rebind(target.id);
366
+ forgetCandidate(client, target.id);
359
367
  const attachedStats = await client.stats();
360
368
  return [
361
369
  `Attached to mailbox ${target.id}${formatAgentNames(target) ? ` (${formatAgentNames(target)})` : ""}.`,
@@ -678,6 +686,35 @@ async function renameThread(client, threadId, name, description) {
678
686
  .filter(Boolean)
679
687
  .join("\n");
680
688
  }
689
+ /**
690
+ * Ce que `whoami` dit des sessions précédentes.
691
+ *
692
+ * Deux choses, et seulement quand elles ont eu lieu : que cette boîte vient d'une session
693
+ * arrêtée — l'agent doit savoir d'où sortent des fils qu'il n'a pas ouverts — et quelles
694
+ * boîtes du projet attendent encore quelqu'un.
695
+ */
696
+ function resumeLines(client) {
697
+ const state = getResumeState(client);
698
+ if (!state)
699
+ return [];
700
+ const lines = [];
701
+ if (state.resumed === "session-key") {
702
+ lines.push("Resumed: this is the mailbox of the session you are continuing (matched on the host session id).");
703
+ }
704
+ else if (state.resumed === "orphan") {
705
+ lines.push("Resumed: this mailbox was left by a stopped session of this project, and no live session held it.", " ↳ its threads and unread messages are not yours from this conversation; read before you answer.");
706
+ }
707
+ if (state.candidates.length > 0) {
708
+ lines.push("Mailboxes left by a stopped session of this project:");
709
+ for (const candidate of state.candidates) {
710
+ const name = candidate.mailbox_name ?? "unnamed mailbox";
711
+ const seen = candidate.last_activity ? ` · last activity ${candidate.last_activity}` : "";
712
+ lines.push(` ${candidate.mailbox} — ${name} · ${candidate.messages} message(s), ${candidate.unread} unread${seen}`);
713
+ }
714
+ lines.push(" ↳ if you are resuming that work, call attach_to_mailbox with its address.");
715
+ }
716
+ return lines;
717
+ }
681
718
  /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
682
719
  function formatThreadRef(title, threadId) {
683
720
  return title ? `${title} (${threadId})` : threadId;
@@ -2,11 +2,14 @@
2
2
  //
3
3
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
4
4
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
5
- // écrites en dur dans ce fichier.
5
+ // écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
6
+ // bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
6
7
  //
7
8
  // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
8
9
  // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
9
10
 
11
+ import { renderMarkdown } from "./markdown.js";
12
+
10
13
  const REFRESH_MS = 5000;
11
14
 
12
15
  // Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
@@ -687,7 +690,7 @@ function messageRow(message, isLast) {
687
690
  if (message.subject && message.subject !== message.thread_title) {
688
691
  column.appendChild(node("p", "message-subject", message.subject));
689
692
  }
690
- column.appendChild(node("p", "message-text", message.body || message.preview || ""));
693
+ column.appendChild(messageText(message));
691
694
 
692
695
  const foot = node("div", "message-foot");
693
696
  foot.appendChild(
@@ -709,6 +712,18 @@ function messageRow(message, isLast) {
709
712
  return item;
710
713
  }
711
714
 
715
+ /**
716
+ * Le corps d'un message, rendu comme du markdown : c'est la langue dans laquelle les
717
+ * agents s'écrivent. L'aperçu, lui, arrive déjà débarrassé de sa syntaxe par le serveur
718
+ * et reste donc du texte simple.
719
+ */
720
+ function messageText(message) {
721
+ if (!message.body) return node("p", "message-text", message.preview || "");
722
+ const container = node("div", "message-text markdown");
723
+ container.appendChild(renderMarkdown(message.body));
724
+ return container;
725
+ }
726
+
712
727
  // --- Volet de détail -------------------------------------------------------
713
728
 
714
729
  function renderSheet(message) {
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Rendu Markdown de la console.
3
+ *
4
+ * Les agents s'écrivent en markdown : un corps de message affiché tel quel se lit mal
5
+ * (titres en dièses, listes en tirets, blocs de code noyés dans le texte). Ce module
6
+ * traduit ce markdown en DOM.
7
+ *
8
+ * Il construit des nœuds, jamais du HTML : tout texte venu d'un message passe par
9
+ * `textContent`, donc rien de ce qu'un agent écrit ne peut devenir une balise. Les seules
10
+ * URL suivies sont http, https et mailto ; le reste retombe en texte.
11
+ *
12
+ * La couverture est celle du markdown qu'écrivent les agents : titres, listes, citations,
13
+ * code, tableaux, filets, emphase, liens. Pas de HTML brut, pas de notes de bas de page.
14
+ */
15
+
16
+ const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)\s*$/;
17
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
18
+ const RULE = /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/;
19
+ const QUOTE = /^ {0,3}>[ \t]?(.*)$/;
20
+ const BULLET = /^(\s*)([-*+])[ \t]+(.*)$/;
21
+ const ORDERED = /^(\s*)(\d{1,9})[.)][ \t]+(.*)$/;
22
+ const TABLE_RULE = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/;
23
+ const SAFE_HREF = /^(https?:\/\/|mailto:)/i;
24
+
25
+ /** Rend un corps de message et renvoie un fragment prêt à être inséré. */
26
+ export function renderMarkdown(source) {
27
+ const fragment = document.createDocumentFragment();
28
+ const lines = String(source ?? "")
29
+ .replace(/\r\n?/g, "\n")
30
+ .split("\n");
31
+ for (const child of parseBlocks(lines)) fragment.appendChild(child);
32
+ return fragment;
33
+ }
34
+
35
+ // --- Blocs -----------------------------------------------------------------
36
+
37
+ function parseBlocks(lines) {
38
+ const out = [];
39
+ let i = 0;
40
+
41
+ while (i < lines.length) {
42
+ const line = lines[i];
43
+
44
+ if (!line.trim()) {
45
+ i += 1;
46
+ continue;
47
+ }
48
+
49
+ const fence = FENCE.exec(line);
50
+ if (fence) {
51
+ const closing = new RegExp(`^ {0,3}\\${fence[1][0]}{${fence[1].length},}\\s*$`);
52
+ const body = [];
53
+ i += 1;
54
+ while (i < lines.length && !closing.test(lines[i])) {
55
+ body.push(lines[i]);
56
+ i += 1;
57
+ }
58
+ i += 1; // la clôture, ou la fin du texte si le bloc n'a jamais été refermé
59
+ out.push(codeBlock(body.join("\n"), fence[2]));
60
+ continue;
61
+ }
62
+
63
+ const heading = HEADING.exec(line);
64
+ if (heading) {
65
+ const el = document.createElement(`h${heading[1].length}`);
66
+ el.className = "md-heading";
67
+ renderInline(heading[2], el);
68
+ out.push(el);
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ if (RULE.test(line)) {
74
+ const hr = document.createElement("hr");
75
+ hr.className = "md-rule";
76
+ out.push(hr);
77
+ i += 1;
78
+ continue;
79
+ }
80
+
81
+ if (QUOTE.test(line)) {
82
+ const inner = [];
83
+ while (i < lines.length && QUOTE.test(lines[i])) {
84
+ inner.push(QUOTE.exec(lines[i])[1]);
85
+ i += 1;
86
+ }
87
+ const quote = document.createElement("blockquote");
88
+ quote.className = "md-quote";
89
+ for (const child of parseBlocks(inner)) quote.appendChild(child);
90
+ out.push(quote);
91
+ continue;
92
+ }
93
+
94
+ if (BULLET.test(line) || ORDERED.test(line)) {
95
+ const [list, next] = parseList(lines, i);
96
+ out.push(list);
97
+ i = next;
98
+ continue;
99
+ }
100
+
101
+ if (opensTable(lines, i)) {
102
+ const [table, next] = parseTable(lines, i);
103
+ if (table) {
104
+ out.push(table);
105
+ i = next;
106
+ continue;
107
+ }
108
+ }
109
+
110
+ // Paragraphe : tout jusqu'à la ligne vide ou au premier bloc d'un autre genre.
111
+ const text = [];
112
+ while (i < lines.length && lines[i].trim() && !(text.length > 0 && startsBlock(lines, i))) {
113
+ text.push(lines[i].trim());
114
+ i += 1;
115
+ }
116
+ const paragraph = document.createElement("p");
117
+ paragraph.className = "md-p";
118
+ renderInline(text.join("\n"), paragraph);
119
+ out.push(paragraph);
120
+ }
121
+
122
+ return out;
123
+ }
124
+
125
+ /** Une ligne qui, au milieu d'un paragraphe, ouvre malgré tout un autre bloc. */
126
+ function startsBlock(lines, i) {
127
+ const line = lines[i];
128
+ return (
129
+ FENCE.test(line) ||
130
+ HEADING.test(line) ||
131
+ RULE.test(line) ||
132
+ QUOTE.test(line) ||
133
+ BULLET.test(line) ||
134
+ ORDERED.test(line) ||
135
+ opensTable(lines, i)
136
+ );
137
+ }
138
+
139
+ function opensTable(lines, i) {
140
+ return i + 1 < lines.length && lines[i].includes("|") && TABLE_RULE.test(lines[i + 1]);
141
+ }
142
+
143
+ function codeBlock(code, language) {
144
+ const pre = document.createElement("pre");
145
+ pre.className = "md-code";
146
+ const el = document.createElement("code");
147
+ if (language) {
148
+ el.className = `language-${language}`;
149
+ pre.dataset.language = language;
150
+ }
151
+ el.textContent = code;
152
+ pre.appendChild(el);
153
+ return pre;
154
+ }
155
+
156
+ /**
157
+ * Une liste et tout ce qu'elle contient. L'indentation seule décide de l'imbrication :
158
+ * une ligne plus rentrée que sa puce appartient à l'article ouvert, et c'est en le
159
+ * relisant comme un texte à part entière que la sous-liste apparaît.
160
+ */
161
+ function parseList(lines, start) {
162
+ const ordered = !BULLET.test(lines[start]);
163
+ const first = ordered ? ORDERED.exec(lines[start]) : BULLET.exec(lines[start]);
164
+ const indent = first[1].length;
165
+
166
+ const list = document.createElement(ordered ? "ol" : "ul");
167
+ list.className = "md-list";
168
+ if (ordered && Number(first[2]) !== 1) list.start = Number(first[2]);
169
+
170
+ let i = start;
171
+ while (i < lines.length) {
172
+ if (!lines[i].trim()) {
173
+ // Une ligne vide ne ferme la liste que si plus rien derrière ne lui appartient.
174
+ if (!continues(lines, i, indent)) break;
175
+ i += 1;
176
+ continue;
177
+ }
178
+
179
+ const match = ordered ? ORDERED.exec(lines[i]) : BULLET.exec(lines[i]);
180
+ if (!match || match[1].length !== indent) break;
181
+
182
+ const own = [match[3]];
183
+ i += 1;
184
+ while (i < lines.length && (lines[i].trim() ? leadingSpaces(lines[i]) > indent : continues(lines, i, indent + 1))) {
185
+ own.push(lines[i].slice(Math.min(leadingSpaces(lines[i]), indent + 2)));
186
+ i += 1;
187
+ }
188
+
189
+ const item = document.createElement("li");
190
+ item.className = "md-item";
191
+ const children = parseBlocks(own);
192
+ // Un article d'une seule ligne n'a pas besoin d'un paragraphe autour de son texte.
193
+ if (children.length === 1 && children[0].tagName === "P") item.append(...children[0].childNodes);
194
+ else for (const child of children) item.appendChild(child);
195
+ list.appendChild(item);
196
+ }
197
+
198
+ return [list, i];
199
+ }
200
+
201
+ /** Vrai si, après la ligne vide `i`, du texte reste rentré d'au moins `indent`. */
202
+ function continues(lines, i, indent) {
203
+ for (let j = i + 1; j < lines.length; j += 1) {
204
+ if (!lines[j].trim()) continue;
205
+ return leadingSpaces(lines[j]) >= indent;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function leadingSpaces(line) {
211
+ return line.length - line.replace(/^[ \t]*/, "").length;
212
+ }
213
+
214
+ function parseTable(lines, start) {
215
+ const header = splitRow(lines[start]);
216
+ const alignments = splitRow(lines[start + 1]).map((cell) => {
217
+ const left = cell.startsWith(":");
218
+ const right = cell.endsWith(":");
219
+ if (left && right) return "center";
220
+ if (right) return "right";
221
+ return left ? "left" : null;
222
+ });
223
+ if (header.length !== alignments.length) return [null, start];
224
+
225
+ const table = document.createElement("table");
226
+ table.className = "md-table";
227
+ const thead = document.createElement("thead");
228
+ thead.appendChild(row(header, alignments, "th"));
229
+ table.appendChild(thead);
230
+
231
+ const tbody = document.createElement("tbody");
232
+ let i = start + 2;
233
+ while (i < lines.length && lines[i].trim() && lines[i].includes("|")) {
234
+ tbody.appendChild(row(splitRow(lines[i]), alignments, "td"));
235
+ i += 1;
236
+ }
237
+ table.appendChild(tbody);
238
+ return [table, i];
239
+ }
240
+
241
+ function splitRow(line) {
242
+ return line
243
+ .trim()
244
+ .replace(/^\|/, "")
245
+ .replace(/\|$/, "")
246
+ .split("|")
247
+ .map((cell) => cell.trim());
248
+ }
249
+
250
+ function row(cells, alignments, tag) {
251
+ const tr = document.createElement("tr");
252
+ alignments.forEach((align, index) => {
253
+ const cell = document.createElement(tag);
254
+ if (align) cell.style.textAlign = align;
255
+ renderInline(cells[index] ?? "", cell);
256
+ tr.appendChild(cell);
257
+ });
258
+ return tr;
259
+ }
260
+
261
+ // --- Enrichissements de ligne ----------------------------------------------
262
+
263
+ /**
264
+ * Les motifs sont confrontés au texte à chaque tour, et celui qui accroche le plus tôt
265
+ * l'emporte. Le code passe en premier à égalité de position : son contenu ne doit plus
266
+ * être relu, un `**` dans un extrait de code est du code et non de la graisse.
267
+ */
268
+ const INLINE = [
269
+ { re: /(`+)([\s\S]+?)\1/, build: (m) => textNode("code", "md-inline-code", m[2].trim()) },
270
+ // La cible tolère une paire de parenthèses : une URL en contient parfois, et le lien
271
+ // ne doit pas se refermer sur la première rencontrée.
272
+ { re: /!\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+"[^"]*")?\)/, build: (m) => image(m[1], m[2]) },
273
+ { re: /\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+"[^"]*")?\)/, build: (m) => link(m[2], m[1]) },
274
+ { re: /<((?:https?:\/\/|mailto:)[^\s>]+)>/, build: (m) => link(m[1], m[1]) },
275
+ { re: /(\*\*|__)(?=\S)([\s\S]+?\S)\1/, build: (m) => inlineNode("strong", "md-strong", m[2]) },
276
+ { re: /(?<![\w*])(\*|_)(?=\S)([^*_\n]+?\S)\1(?![\w*])/, build: (m) => inlineNode("em", "md-em", m[2]) },
277
+ { re: /~~(?=\S)([\s\S]+?\S)~~/, build: (m) => inlineNode("del", "md-del", m[1]) },
278
+ { re: /(?<![("[])https?:\/\/[^\s<>"'`]*[^\s<>"'`.,;:!?)\]]/, build: (m) => link(m[0], m[0]) },
279
+ ];
280
+
281
+ function renderInline(text, parent) {
282
+ let rest = text;
283
+
284
+ while (rest) {
285
+ let best = null;
286
+ for (const pattern of INLINE) {
287
+ const match = pattern.re.exec(rest);
288
+ if (match && (!best || match.index < best.match.index)) best = { match, pattern };
289
+ if (best && best.match.index === 0) break;
290
+ }
291
+
292
+ if (!best) break;
293
+ appendText(parent, rest.slice(0, best.match.index));
294
+ parent.appendChild(best.pattern.build(best.match));
295
+ rest = rest.slice(best.match.index + best.match[0].length);
296
+ }
297
+
298
+ appendText(parent, rest);
299
+ }
300
+
301
+ /** Les retours à la ligne d'un paragraphe sont voulus : un agent aligne, il n'habille pas. */
302
+ function appendText(parent, text) {
303
+ if (!text) return;
304
+ text.split("\n").forEach((part, index) => {
305
+ if (index > 0) parent.appendChild(document.createElement("br"));
306
+ if (part) parent.appendChild(document.createTextNode(part));
307
+ });
308
+ }
309
+
310
+ function textNode(tag, className, text) {
311
+ const el = document.createElement(tag);
312
+ el.className = className;
313
+ el.textContent = text;
314
+ return el;
315
+ }
316
+
317
+ function inlineNode(tag, className, text) {
318
+ const el = document.createElement(tag);
319
+ el.className = className;
320
+ renderInline(text, el);
321
+ return el;
322
+ }
323
+
324
+ function link(href, label) {
325
+ if (!SAFE_HREF.test(href)) return document.createTextNode(label || href);
326
+ const el = document.createElement("a");
327
+ el.className = "md-link";
328
+ el.href = href;
329
+ el.target = "_blank";
330
+ el.rel = "noopener noreferrer";
331
+ appendText(el, label || href);
332
+ return el;
333
+ }
334
+
335
+ /** Une image distante est montrée par son intitulé et son lien : la console ne va rien chercher dehors. */
336
+ function image(alt, src) {
337
+ const el = link(src, alt || src);
338
+ if (el.nodeType === 1) el.classList.add("md-image-link");
339
+ return el;
340
+ }
@@ -1039,6 +1039,151 @@ body {
1039
1039
  overflow-wrap: anywhere;
1040
1040
  }
1041
1041
 
1042
+ /* Le markdown est déjà mis en forme : plus besoin de conserver les blancs du source. */
1043
+ .message-text.markdown {
1044
+ white-space: normal;
1045
+ }
1046
+
1047
+ /* --- Corps de message rendu en markdown --- */
1048
+
1049
+ .markdown > *:first-child {
1050
+ margin-top: 0;
1051
+ }
1052
+
1053
+ .markdown > *:last-child {
1054
+ margin-bottom: 0;
1055
+ }
1056
+
1057
+ .md-p {
1058
+ margin: 0 0 8px;
1059
+ }
1060
+
1061
+ .markdown .md-heading {
1062
+ margin: 14px 0 6px;
1063
+ line-height: 1.35;
1064
+ font-weight: 600;
1065
+ }
1066
+
1067
+ .markdown h1.md-heading {
1068
+ font-size: 17px;
1069
+ }
1070
+
1071
+ .markdown h2.md-heading {
1072
+ font-size: 15px;
1073
+ }
1074
+
1075
+ .markdown h3.md-heading,
1076
+ .markdown h4.md-heading,
1077
+ .markdown h5.md-heading,
1078
+ .markdown h6.md-heading {
1079
+ font-size: 14px;
1080
+ }
1081
+
1082
+ .markdown h4.md-heading,
1083
+ .markdown h5.md-heading,
1084
+ .markdown h6.md-heading {
1085
+ color: var(--muted);
1086
+ }
1087
+
1088
+ .md-list {
1089
+ margin: 0 0 8px;
1090
+ padding-left: 20px;
1091
+ }
1092
+
1093
+ .md-item {
1094
+ margin: 2px 0;
1095
+ }
1096
+
1097
+ /* Un article qui porte une sous-liste passe par un paragraphe : il ne doit pas s'en écarter. */
1098
+ .md-item > .md-p {
1099
+ margin-bottom: 2px;
1100
+ }
1101
+
1102
+ .md-list .md-list {
1103
+ margin: 2px 0;
1104
+ }
1105
+
1106
+ .md-quote {
1107
+ margin: 0 0 8px;
1108
+ padding: 2px 0 2px 10px;
1109
+ border-left: 3px solid var(--border);
1110
+ color: var(--muted);
1111
+ }
1112
+
1113
+ .md-rule {
1114
+ margin: 12px 0;
1115
+ border: 0;
1116
+ border-top: 1px solid var(--border);
1117
+ }
1118
+
1119
+ /* La langue annoncée par l'agent, posée en coin du bloc : elle dit ce qu'on lit. */
1120
+ .md-code[data-language]::before {
1121
+ content: attr(data-language);
1122
+ display: block;
1123
+ margin-bottom: 6px;
1124
+ color: var(--muted);
1125
+ font-size: 11px;
1126
+ letter-spacing: 0.04em;
1127
+ text-transform: uppercase;
1128
+ }
1129
+
1130
+ .md-code {
1131
+ margin: 0 0 8px;
1132
+ padding: 10px 12px;
1133
+ border: 1px solid var(--border-soft);
1134
+ border-radius: var(--radius);
1135
+ background: var(--neutral-soft);
1136
+ overflow-x: auto;
1137
+ }
1138
+
1139
+ .md-code code {
1140
+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1141
+ white-space: pre;
1142
+ }
1143
+
1144
+ .md-inline-code {
1145
+ padding: 1px 5px;
1146
+ border-radius: 4px;
1147
+ background: var(--neutral-soft);
1148
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1149
+ overflow-wrap: anywhere;
1150
+ }
1151
+
1152
+ .md-link {
1153
+ color: var(--accent);
1154
+ overflow-wrap: anywhere;
1155
+ }
1156
+
1157
+ .md-strong {
1158
+ font-weight: 600;
1159
+ }
1160
+
1161
+ .md-del {
1162
+ color: var(--muted);
1163
+ }
1164
+
1165
+ .md-table {
1166
+ display: block;
1167
+ width: max-content;
1168
+ max-width: 100%;
1169
+ margin: 0 0 8px;
1170
+ border-collapse: collapse;
1171
+ overflow-x: auto;
1172
+ }
1173
+
1174
+ .md-table th,
1175
+ .md-table td {
1176
+ padding: 5px 10px;
1177
+ border: 1px solid var(--border-soft);
1178
+ text-align: left;
1179
+ vertical-align: top;
1180
+ }
1181
+
1182
+ .md-table th {
1183
+ background: var(--neutral-soft);
1184
+ font-weight: 600;
1185
+ }
1186
+
1042
1187
  .message-foot {
1043
1188
  display: flex;
1044
1189
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.15";
1
+ export declare const APP_VERSION = "1.0.17";
@@ -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.15";
2
+ export const APP_VERSION = "1.0.17";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",
@@ -49,6 +49,7 @@
49
49
  "@types/express": "^5.0.6",
50
50
  "@types/node": "^22.20.1",
51
51
  "@vitest/coverage-v8": "^3.2.7",
52
+ "jsdom": "^29.1.1",
52
53
  "typescript": "^5.9.3",
53
54
  "vitest": "^3.2.7"
54
55
  },