@imenam/mcp-messenger 1.0.16 → 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
@@ -518,6 +559,7 @@ src/
518
559
  server/ui/markdown.js # rendu markdown du corps des messages, en nœuds DOM
519
560
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
520
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
521
563
  client/ # client HTTP utilisé par le mode MCP
522
564
  mcp/ # définition des outils et serveur MCP stdio
523
565
  test/ # store, config, API HTTP, outils MCP, binaire réel
@@ -525,7 +567,8 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
525
567
 
526
568
  ### Tests
527
569
 
528
- 187 tests couvrant : le rendu markdown des messages et l'aplatissement des aperçus (dont
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
529
572
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
530
573
  ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
531
574
  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) {
@@ -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;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.16";
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.16";
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.16",
3
+ "version": "1.0.17",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",