@imenam/mcp-messenger 1.0.16 → 1.0.18

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
@@ -336,7 +377,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
336
377
 
337
378
  ## Console web
338
379
 
339
- `http://127.0.0.1:3600/ui/` affiche, en lecture seule, **tous les échanges** de la flotte,
380
+ `http://127.0.0.1:3600/ui/` affiche **tous les échanges** de la flotte,
340
381
  organisés autour du fil — l'unité d'une correspondance, celle qui porte le sujet, les deux
341
382
  correspondants et l'état de lecture :
342
383
 
@@ -345,7 +386,8 @@ correspondants et l'état de lecture :
345
386
  chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
346
387
  la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
347
388
  **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
348
- replie sur les seuls avatars d'agents quand la place manque.
389
+ replie sur les seuls avatars d'agents quand la place manque, son bouton de repli restant
390
+ en place : c'est lui qui la rouvre.
349
391
 
350
392
  **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
351
393
  vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
@@ -398,7 +440,31 @@ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton
398
440
  `Authorization: Bearer` pour les scripts. Le formulaire est limité à 10 tentatives par
399
441
  quart d'heure et par IP.
400
442
 
401
- La console n'écrit jamais : elle ne peut ni envoyer, ni supprimer, ni modifier.
443
+ La console n'envoie ni ne modifie aucun message. Elle ne commande qu'une seule écriture,
444
+ et c'est une suppression : la **purge des boîtes inactives**, derrière l'icône de corbeille
445
+ de la barre du haut.
446
+
447
+ Une boîte est inactive quand plus rien ne l'a touchée depuis la date choisie : ni
448
+ connexion, ni message reçu ou envoyé. **Une boîte créée après cette date est épargnée,
449
+ même vide** — une session qui vient de démarrer et n'a pas encore parlé n'est pas une
450
+ boîte à l'abandon.
451
+
452
+ La purge se fait en deux temps, parce qu'elle ne se rattrape pas :
453
+
454
+ 1. **L'aperçu** liste les boîtes concernées, chacune avec son nom, son adresse, son nombre
455
+ de messages et sa dernière activité. Chaque ligne se décoche.
456
+ 2. **La confirmation** demande deux clics, et n'envoie que les adresses affichées. Le
457
+ serveur, lui, reconfronte chacune à la date avant de supprimer : une boîte redevenue
458
+ active entre l'aperçu et la confirmation fait échouer la purge entière, nommément.
459
+ Changer la date ou décocher une ligne annule une confirmation déjà donnée.
460
+
461
+ **Ce qui disparaît.** Un message a deux copies, une par correspondant : supprimer une
462
+ boîte n'emporte que sa moitié. Le message lui-même ne s'efface qu'une fois sa dernière
463
+ copie partie, autrement dit quand plus personne ne pourrait le lire ; un fil vidé de ses
464
+ messages s'en va avec. Ce que l'autre côté a reçu reste intact, expéditeur compris.
465
+
466
+ Le registre local des boîtes n'a pas à être nettoyé après une purge : une entrée qui
467
+ désigne une boîte disparue n'est jamais reprise, et s'oublie d'elle-même.
402
468
 
403
469
  ---
404
470
 
@@ -465,6 +531,8 @@ la console (cookie), qui elle n'ouvre rien d'autre.
465
531
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
466
532
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
467
533
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
534
+ | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
535
+ | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`before`, `mailboxes`) — refusé si l'une d'elles est redevenue active |
468
536
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
469
537
 
470
538
  ---
@@ -484,8 +552,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
484
552
  dans les logs).
485
553
  - **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
486
554
  un agent sont systématiquement filtrés sur ses propres livraisons.
487
- - La console web n'insère jamais de HTML fourni par un agent (`textContent` uniquement) et
488
- la CSP interdit tout script tiers.
555
+ - La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
556
+ rendu en markdown par construction de nœuds, jamais par assignation de balisage, et la
557
+ CSP interdit tout script tiers.
558
+ - La console ne peut ni écrire ni modifier un message. Sa seule commande destructrice est
559
+ la purge des boîtes inactives, réservée au jeton d'administration, confirmée deux fois et
560
+ revérifiée par le serveur avant exécution.
489
561
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
490
562
  agents via `MESSENGER_SERVER_URL`.
491
563
 
@@ -518,6 +590,7 @@ src/
518
590
  server/ui/markdown.js # rendu markdown du corps des messages, en nœuds DOM
519
591
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
520
592
  cli/watch-handoff.ts # relais 0600 entre la session MCP et son veilleur
593
+ mcp/mailbox-registry.ts # registre local des boîtes : reprise d'une session interrompue
521
594
  client/ # client HTTP utilisé par le mode MCP
522
595
  mcp/ # définition des outils et serveur MCP stdio
523
596
  test/ # store, config, API HTTP, outils MCP, binaire réel
@@ -525,7 +598,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
525
598
 
526
599
  ### Tests
527
600
 
528
- 187 tests couvrant : le rendu markdown des messages et l'aplatissement des aperçus (dont
601
+ 231 tests couvrant : la purge des boîtes inactives commandée depuis la console (aperçu,
602
+ double confirmation, refus nominatif d'une boîte redevenue active), la reprise de la boîte d'une session interrompue (clé de session de
603
+ 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
529
604
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
530
605
  ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
531
606
  l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
@@ -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) {
@@ -87,6 +87,31 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
87
87
  stats(agentId: string): MailboxStats;
88
88
  /** Purge les livraisons restées en corbeille au-delà de `days`. Renvoie le nombre supprimé. */
89
89
  purgeTrash(days: number): number;
90
+ /**
91
+ * Boîtes dont plus rien ne bouge depuis `before`, avec ce que leur suppression coûterait.
92
+ *
93
+ * L'inactivité se mesure au plus récent de trois moments : la dernière connexion, la
94
+ * dernière livraison, et la création. Le dernier compte autant que les autres — une
95
+ * boîte allouée il y a une minute et qui n'a rien fait n'est pas une boîte à l'abandon,
96
+ * c'est une session qui n'a pas encore parlé.
97
+ */
98
+ staleMailboxes(before: string): (AgentDirectoryEntry & {
99
+ messages: number;
100
+ })[];
101
+ /**
102
+ * Supprime ces boîtes et tout ce qui n'appartenait qu'à elles.
103
+ *
104
+ * Un message a deux copies, une par correspondant : effacer une boîte n'efface donc que
105
+ * sa moitié. Le message lui-même ne disparaît qu'une fois sa dernière copie partie —
106
+ * autrement dit quand plus personne ne peut le lire. Ce que l'autre côté a reçu reste
107
+ * intact, avec l'adresse de l'expéditeur telle qu'elle a été livrée.
108
+ */
109
+ deleteMailboxes(ids: string[]): {
110
+ mailboxes: number;
111
+ deliveries: number;
112
+ messages: number;
113
+ threads: number;
114
+ };
90
115
  /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
91
116
  listAllMessages(opts?: {
92
117
  search?: string;
@@ -581,7 +581,64 @@ export class MessageStore extends EventEmitter {
581
581
  this.dropOrphanMessages();
582
582
  return removed;
583
583
  }
584
- // --- Supervision (interface web, lecture seule) --------------------------
584
+ /**
585
+ * Boîtes dont plus rien ne bouge depuis `before`, avec ce que leur suppression coûterait.
586
+ *
587
+ * L'inactivité se mesure au plus récent de trois moments : la dernière connexion, la
588
+ * dernière livraison, et la création. Le dernier compte autant que les autres — une
589
+ * boîte allouée il y a une minute et qui n'a rien fait n'est pas une boîte à l'abandon,
590
+ * c'est une session qui n'a pas encore parlé.
591
+ */
592
+ staleMailboxes(before) {
593
+ const rows = this.db
594
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
595
+ (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
596
+ FROM agents a
597
+ WHERE MAX(${LAST_ACTIVITY}, a.created_at) < ?
598
+ ORDER BY MAX(${LAST_ACTIVITY}, a.created_at) ASC`)
599
+ .all(before);
600
+ return rows.map((row) => ({
601
+ ...row,
602
+ registered: Boolean(row.registered),
603
+ last_activity_at: row.last_activity_at || null,
604
+ }));
605
+ }
606
+ /**
607
+ * Supprime ces boîtes et tout ce qui n'appartenait qu'à elles.
608
+ *
609
+ * Un message a deux copies, une par correspondant : effacer une boîte n'efface donc que
610
+ * sa moitié. Le message lui-même ne disparaît qu'une fois sa dernière copie partie —
611
+ * autrement dit quand plus personne ne peut le lire. Ce que l'autre côté a reçu reste
612
+ * intact, avec l'adresse de l'expéditeur telle qu'elle a été livrée.
613
+ */
614
+ deleteMailboxes(ids) {
615
+ if (ids.length === 0)
616
+ return { mailboxes: 0, deliveries: 0, messages: 0, threads: 0 };
617
+ return this.db.transaction(() => {
618
+ const messagesBefore = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
619
+ const threadsBefore = this.db.prepare("SELECT COUNT(*) AS n FROM threads").get().n;
620
+ let mailboxes = 0;
621
+ let deliveries = 0;
622
+ const dropDeliveries = this.db.prepare("DELETE FROM deliveries WHERE agent_id = ?");
623
+ const dropFolders = this.db.prepare("DELETE FROM folders WHERE agent_id = ?");
624
+ const dropAgent = this.db.prepare("DELETE FROM agents WHERE id = ?");
625
+ for (const id of ids) {
626
+ deliveries += dropDeliveries.run(id).changes;
627
+ dropFolders.run(id);
628
+ mailboxes += dropAgent.run(id).changes;
629
+ }
630
+ this.dropOrphanMessages();
631
+ const messagesAfter = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
632
+ const threadsAfter = this.db.prepare("SELECT COUNT(*) AS n FROM threads").get().n;
633
+ return {
634
+ mailboxes,
635
+ deliveries,
636
+ messages: messagesBefore - messagesAfter,
637
+ threads: threadsBefore - threadsAfter,
638
+ };
639
+ })();
640
+ }
641
+ // --- Supervision (interface web, lecture seule sauf purge) ---------------
585
642
  /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
586
643
  listAllMessages(opts = {}) {
587
644
  const limit = clamp(opts.limit ?? 50, 1, MAX_LIMIT);
@@ -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>;