@imenam/mcp-messenger 1.0.5 → 1.0.7

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
@@ -13,7 +13,8 @@ MESSENGER_PORT=3600
13
13
  # Chemin du fichier SQLite (défaut : <cwd>/data/messenger.db).
14
14
  # MESSENGER_DB_PATH=/var/lib/mcp-messenger/messenger.db
15
15
 
16
- # Jeton exigé sur TOUTE requête entrante (API, /health, console web).
16
+ # Jeton exigé sur TOUTE requête de données (API, /health, données de la console).
17
+ # Seule la page de la console (/ui), qui ne contient aucun secret, est servie sans lui.
17
18
  # OBLIGATOIRE : sans MESSENGER_TOKEN ni MESSENGER_AGENT_TOKENS, le serveur refuse
18
19
  # de démarrer. Générer avec : npx @imenam/mcp-messenger --generate-token
19
20
  MESSENGER_TOKEN=change-me
@@ -23,8 +24,10 @@ MESSENGER_TOKEN=change-me
23
24
  # Format : agent_id:token,agent_id:token
24
25
  # MESSENGER_AGENT_TOKENS=planner:tok-planner,coder:tok-coder
25
26
 
26
- # Jeton de la console web. À défaut, MESSENGER_TOKEN fait office ; il devient
27
- # obligatoire si seul MESSENGER_AGENT_TOKENS est utilisé.
27
+ # Jeton de la console web, saisi une fois dans son formulaire (/ui) : le serveur ouvre
28
+ # alors une session de 12 h dans un cookie HttpOnly, sans jamais y mettre le jeton.
29
+ # À défaut, MESSENGER_TOKEN fait office ; il devient obligatoire si seul
30
+ # MESSENGER_AGENT_TOKENS est utilisé.
28
31
  # MESSENGER_ADMIN_TOKEN=change-me-too
29
32
 
30
33
  # Rate limiting (par IP).
package/README.md CHANGED
@@ -43,7 +43,7 @@ npx @imenam/mcp-messenger --generate-token
43
43
  MESSENGER_TOKEN=<le-jeton> npx @imenam/mcp-messenger --server
44
44
  # → listening on http://127.0.0.1:3600 (loopback only)
45
45
  # → auth: shared token — required on every request
46
- # → web console: http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>
46
+ # → web console: http://127.0.0.1:3600/ui/ (asks for MESSENGER_ADMIN_TOKEN once)
47
47
  ```
48
48
 
49
49
  Deux invariants, non négociables et vérifiés par les tests :
@@ -51,9 +51,10 @@ Deux invariants, non négociables et vérifiés par les tests :
51
51
  1. **Le serveur écoute toujours sur `127.0.0.1`.** `MESSENGER_HOST` n'est pas un réglage :
52
52
  toute autre valeur fait échouer le démarrage. Pour joindre la boîte depuis une autre
53
53
  machine, il faut placer un reverse proxy (TLS) devant.
54
- 2. **Un jeton est obligatoire et vérifié sur chaque requête entrante** — API, `/health` et
55
- console web comprises. Sans `MESSENGER_TOKEN` ni `MESSENGER_AGENT_TOKENS`, le serveur
56
- refuse de démarrer.
54
+ 2. **Un jeton est obligatoire et vérifié sur chaque requête de données** — API, `/health`
55
+ et données de la console (`/admin`) comprises. Seule la page de la console, qui ne
56
+ contient aucun secret, est servie librement. Sans `MESSENGER_TOKEN` ni
57
+ `MESSENGER_AGENT_TOKENS`, le serveur refuse de démarrer.
57
58
 
58
59
  ### 2. Déclarer le MCP côté agent
59
60
 
@@ -96,7 +97,7 @@ la session MCP démarre quand même et l'enregistrement se fera au premier appel
96
97
 
97
98
  L'agent A appelle `send_message` avec `to: ["coder"]`, l'agent B voit le message via
98
99
  `list_messages` puis `read_message`, et répond avec `reply_to` pour rester dans le fil.
99
- Le fil se nomme au passage (`thread_title` sur `send_message`, ou `set_label` plus tard).
100
+ Le fil se nomme au passage (`thread_name` sur `send_message`, ou `rename_thread` plus tard).
100
101
  `wait_for_message` permet d'attendre une réponse sans faire tourner une boucle d'appels : il ne
101
102
  rend la main que sur un message arrivé *après* le début de l'attente, les non-lus déjà en boîte
102
103
  ne l'interrompent pas.
@@ -176,10 +177,11 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
176
177
  |---|---|
177
178
  | `whoami` | Adresse de cette session, serveur connecté, veilleur armé ou non, compteurs par dossier |
178
179
  | `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
179
- | `set_label` | Nommer sa boîte, ou un fil (`thread_id`), en langue naturelle |
180
+ | `rename_mailbox` | Nommer ou renommer sa boîte (de quoi la session s'occupe) ; le nom de l'agent, posé par la configuration, reste |
181
+ | `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
180
182
  | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité |
181
- | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien |
182
- | `send_message` | Envoyer à un ou plusieurs agents (`to`, `cc`, `priority`, `reply_to`, `thread_title`) |
183
+ | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien ; la boîte de l'appelant n'y figure pas |
184
+ | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
183
185
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
184
186
  | `read_message` | Lire un message en entier ; le marque lu par défaut |
185
187
  | `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
@@ -193,12 +195,18 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
193
195
 
194
196
  **Noms lisibles** — voir « Nommer les boîtes et les fils » ci-dessous.
195
197
 
198
+ **Un message, deux boîtes** — un échange lie exactement deux agents *distincts* : `to` prend
199
+ une seule adresse, il n'y a ni diffusion ni copie, et une boîte ne peut pas s'écrire à
200
+ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dans son propre
201
+ fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
202
+ ne peut pas viser n'a pas à figurer dans son annuaire.
203
+
196
204
  **Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
197
205
  `trash`. Les dossiers personnalisés sont propres à chaque boîte.
198
206
 
199
207
  **Ce que voit un agent** : uniquement les livraisons de la boîte à laquelle il est attaché. Un message adressé à `coder`
200
208
  est invisible pour tout autre agent, y compris via son identifiant. Supprimer un message ne
201
- touche que sa propre copie : les autres destinataires gardent la leur.
209
+ touche que sa propre copie : l'autre partie garde la sienne.
202
210
 
203
211
  ---
204
212
 
@@ -220,25 +228,45 @@ ont deux boîtes distinctes et ne se volent pas leur courrier.
220
228
  2. Transmets-la à l'agent B, qui l'utilise dans `send_message`.
221
229
 
222
230
  Comme les adresses sont opaques, demande aux agents de nommer leur boîte
223
- (`set_label`) : le libellé apparaît dans `list_agents` et dans la console web, ce
231
+ (`rename_mailbox`) : le nom apparaît dans `list_agents` et dans la console web, ce
224
232
  qui permet de retrouver une adresse perdue.
225
233
 
234
+ ### Deux noms par boîte : l'agent et la boîte
235
+
236
+ Une boîte porte deux identités, qui répondent à deux questions différentes et ne changent
237
+ pas au même rythme :
238
+
239
+ | | D'où vient-il ? | De quoi s'occupe-t-il ? |
240
+ |---|---|---|
241
+ | Champ | `agent_name`, `agent_description` | `display_name`, `description` |
242
+ | Posé par | la configuration (`MESSENGER_AGENT_NAME`, `MESSENGER_AGENT_DESCRIPTION`) | l'agent lui-même, via `rename_mailbox` |
243
+ | Change | jamais depuis une session | librement, au fil du travail |
244
+ | Exemple | `Ai Corrector Dev` | `Correction du parseur de copies` |
245
+
246
+ Le nom de l'agent seul ne distingue pas deux sessions ouvertes sur le même projet ; le nom
247
+ de la boîte seul ne dit pas d'où l'agent écrit. L'annuaire (`list_agents`), `whoami` et la
248
+ console web montrent donc toujours les deux, sous la forme `Agent · Boîte`. Renommer la
249
+ boîte ne touche jamais au nom de l'agent.
250
+
226
251
  ### Nommer les boîtes et les fils
227
252
 
228
253
  Les identifiants (`box_…`, `thr_…`) ne disent rien de ce qui se passe. Deux noms en langue
229
- naturelle viennent par-dessus, et un seul outil les pose :
254
+ naturelle viennent par-dessus, un outil pour chacun :
230
255
 
231
256
  ```
232
- set_label(label: "Développement des tests unitaires du parser")
233
- → nomme la boîte de la session
257
+ rename_mailbox(name: "Développement des tests unitaires du parser")
258
+ → nomme la boîte de la session (le nom de l'agent reste)
234
259
 
235
- set_label(thread_id: "thr_…", label: "Le test échoue sur un bug applicatif")
260
+ rename_thread(thread_id: "thr_…", name: "Le test échoue sur un bug applicatif")
236
261
  → nomme le fil
237
262
  ```
238
263
 
239
- Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_title` sur
264
+ Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_name` sur
240
265
  `send_message` : l'agent qui écrit le premier message sait déjà de quoi il parle.
241
266
 
267
+ L'ancien outil `set_label`, qui cumulait les deux rôles, reste accepté sans être publié :
268
+ une session lancée avant la mise à jour peut encore l'appeler.
269
+
242
270
  Un fil n'a **qu'un seul nom, partagé par tous ses participants** — ils discutent du même
243
271
  sujet, ils doivent lire le même intitulé. N'importe quel participant peut le renommer, et
244
272
  le dernier a le dernier mot ; le serveur retient qui (`named_by`). Un agent extérieur au
@@ -273,8 +301,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
273
301
 
274
302
  ## Console web
275
303
 
276
- `http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>` affiche, en lecture seule,
277
- **tous les échanges** de la flotte :
304
+ `http://127.0.0.1:3600/ui/` affiche, en lecture seule, **tous les échanges** de la flotte :
278
305
 
279
306
  - la liste des agents avec leurs compteurs (envoyés, reçus, non lus) ;
280
307
  - tous les messages, filtrables par agent, par fil ou par recherche texte ;
@@ -282,14 +309,18 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
282
309
  - le fil complet d'une conversation ;
283
310
  - un rafraîchissement automatique toutes les 5 secondes.
284
311
 
285
- Un navigateur ne peut pas poser d'en-tête sur une navigation : le jeton passe donc par
286
- `?token=` dans l'URL, et le serveur le propage aux ressources de la page (`styles.css`,
287
- `app.js`), elles aussi protégées. Ouvrir `/ui/` sans jeton renvoie un `401`. En dernier
288
- recours, la console propose un champ pour saisir le jeton et le conserve dans le
289
- `localStorage` du navigateur.
312
+ À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
313
+ le vérifie (`POST /ui/login`) et ouvre une **session** de 12 heures, portée par un cookie
314
+ `HttpOnly` + `SameSite=Strict` (+ `Secure` en HTTPS). Le cookie ne contient jamais le
315
+ jeton, seulement un identifiant de session opaque, oublié au redémarrage du serveur ou
316
+ sur « Se déconnecter ». Le navigateur ne stocke rien d'autre, et le jeton n'apparaît
317
+ jamais dans une URL.
290
318
 
291
- > Le jeton apparaissant dans l'URL, il se retrouve dans l'historique du navigateur. C'est
292
- > le prix du « jeton exigé sur absolument tout », y compris la page elle-même.
319
+ Cette session n'est reconnue que sur `/ui` et `/admin` : elle ne permet pas d'appeler
320
+ l'API des agents. Un site tiers ne peut donc pas faire agir un agent à travers le
321
+ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton admin en
322
+ `Authorization: Bearer` pour les scripts. Le formulaire est limité à 10 tentatives par
323
+ quart d'heure et par IP.
293
324
 
294
325
  La console n'écrit jamais : elle ne peut ni envoyer, ni supprimer, ni modifier.
295
326
 
@@ -321,7 +352,7 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
321
352
  | `MESSENGER_AGENT_ID` | — | Adresse **fixe**. Vide (recommandé) = une boîte par session |
322
353
  | `MESSENGER_SERVER_URL` | ✅ | `http://` ou `https://`, local ou distant |
323
354
  | `MESSENGER_TOKEN` | ✅ | Jeton présenté au serveur, sur chaque appel |
324
- | `MESSENGER_AGENT_NAME` | — | Nom affiché dans l'annuaire |
355
+ | `MESSENGER_AGENT_NAME` | — | Nom de l'agent (le projet, typiquement), affiché à côté du nom de la boîte |
325
356
  | `MESSENGER_AGENT_DESCRIPTION` | — | À quoi sert cet agent, pour ses correspondants |
326
357
  | `MESSENGER_TIMEOUT_MS` | `15000` | Délai maximal d'un appel HTTP |
327
358
  | `MESSENGER_STATE_DIR` | — | Répertoire des relais vers le veilleur (défaut : `~/.mcp-messenger`) |
@@ -330,16 +361,17 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
330
361
 
331
362
  ## API HTTP
332
363
 
333
- **Toutes** les routes exigent un jeton, présenté au choix par `Authorization: Bearer
334
- <token>`, l'en-tête `X-Messenger-Token`, ou `?token=` (réservé à la navigation navigateur).
335
- Les routes `/api/*` exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à
336
- cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
364
+ **Toutes** les routes de données exigent un jeton, présenté par `Authorization: Bearer
365
+ <token>` ou par l'en-tête `X-Messenger-Token` — jamais dans l'URL. Les routes `/api/*`
366
+ exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à cet agent lorsque
367
+ `MESSENGER_AGENT_TOKENS` est utilisé. Les routes `/admin/*` acceptent aussi la session de
368
+ la console (cookie), qui elle n'ouvre rien d'autre.
337
369
 
338
370
  | Méthode | Route | Rôle |
339
371
  |---|---|---|
340
372
  | `GET` | `/health` | État du service |
341
373
  | `POST` | `/api/agents/register` | S'inscrire à l'annuaire (`fresh: true` = exige une adresse neuve) |
342
- | `GET` | `/api/agents` | Annuaire des boîtes |
374
+ | `GET` | `/api/agents` | Annuaire des boîtes, sauf celle de l'appelant |
343
375
  | `GET` | `/api/agents/:id` | Une boîte, ou 404 |
344
376
  | `PATCH` | `/api/agents/me` | Renommer la boîte courante |
345
377
  | `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
@@ -355,6 +387,7 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
355
387
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
356
388
  | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
357
389
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` · `/admin/threads/:id` | Supervision, lecture seule |
390
+ | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
358
391
 
359
392
  ---
360
393
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Génère un jeton aléatoire cryptographiquement sûr.
3
3
  *
4
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
5
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
4
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
5
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
6
6
  */
7
7
  export declare function generateToken(bytes?: number): string;
@@ -4,8 +4,8 @@ const DEFAULT_BYTES = 32;
4
4
  /**
5
5
  * Génère un jeton aléatoire cryptographiquement sûr.
6
6
  *
7
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
8
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
7
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
8
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
9
9
  */
10
10
  export function generateToken(bytes = DEFAULT_BYTES) {
11
11
  return randomBytes(bytes).toString("base64url");
@@ -44,7 +44,7 @@ const REQUIRED_VARS = [
44
44
  ];
45
45
  const OPTIONAL_VARS = [
46
46
  ["MESSENGER_AGENT_ID", "adresse fixe : à laisser vide pour une boîte par session"],
47
- ["MESSENGER_AGENT_NAME", "nom affiché dans l'annuaire des agents"],
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
50
  ];
@@ -140,7 +140,7 @@ export function runSetupMcp({ cwd, force }) {
140
140
  console.log("");
141
141
  console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton :");
142
142
  console.log(` MESSENGER_TOKEN=<votre-jeton> npx -y ${PACKAGE_NAME} --server`);
143
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/?token=<votre-jeton>`);
143
+ console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton y est demandé une fois)`);
144
144
  console.log("");
145
145
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
146
146
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
@@ -44,8 +44,8 @@ export function resolveWatchConfig(mailbox, env = process.env) {
44
44
  agentId: target,
45
45
  serverUrl: serverUrl.replace(/\/+$/, ""),
46
46
  token: handoff?.token ?? env.MESSENGER_TOKEN?.trim() ?? null,
47
- displayName: null,
48
- description: null,
47
+ agentName: null,
48
+ agentDescription: null,
49
49
  timeoutMs: Number(env.MESSENGER_TIMEOUT_MS) || 15_000,
50
50
  },
51
51
  };
@@ -52,7 +52,7 @@ export declare class MessengerClient {
52
52
  register(options?: {
53
53
  fresh?: boolean;
54
54
  }): Promise<Agent>;
55
- /** Renomme la boîte courante, pour la rendre reconnaissable dans l'annuaire. */
55
+ /** Renomme la boîte courante (pas l'agent), pour dire de quoi cette session s'occupe. */
56
56
  updateProfile(profile: {
57
57
  display_name?: string | null;
58
58
  description?: string | null;
@@ -61,8 +61,7 @@ export declare class MessengerClient {
61
61
  listAgents(): Promise<AgentDirectoryEntry[]>;
62
62
  stats(): Promise<MailboxStats>;
63
63
  sendMessage(input: {
64
- to: string[];
65
- cc?: string[];
64
+ to: string;
66
65
  subject: string;
67
66
  body: string;
68
67
  priority?: Priority;
@@ -68,14 +68,14 @@ export class MessengerClient {
68
68
  async register(options = {}) {
69
69
  const { agent } = await this.request("POST", "/api/agents/register", {
70
70
  body: {
71
- display_name: this.config.displayName,
72
- description: this.config.description,
71
+ agent_name: this.config.agentName,
72
+ agent_description: this.config.agentDescription,
73
73
  fresh: options.fresh ?? false,
74
74
  },
75
75
  });
76
76
  return agent;
77
77
  }
78
- /** Renomme la boîte courante, pour la rendre reconnaissable dans l'annuaire. */
78
+ /** Renomme la boîte courante (pas l'agent), pour dire de quoi cette session s'occupe. */
79
79
  async updateProfile(profile) {
80
80
  const { agent } = await this.request("PATCH", "/api/agents/me", { body: profile });
81
81
  return agent;
@@ -27,8 +27,9 @@ export interface AgentConfig {
27
27
  agentId: string | null;
28
28
  serverUrl: string;
29
29
  token: string | null;
30
- displayName: string | null;
31
- description: string | null;
30
+ /** Identité de l'agent, stable : d'où il écrit (le projet). Distincte du nom de la boîte. */
31
+ agentName: string | null;
32
+ agentDescription: string | null;
32
33
  timeoutMs: number;
33
34
  }
34
35
  export declare function isLoopbackHost(host: string): boolean;
@@ -74,8 +74,8 @@ export function loadAgentConfig(env = process.env) {
74
74
  agentId,
75
75
  serverUrl: rawUrl.replace(/\/+$/, ""),
76
76
  token: nonEmpty(env.MESSENGER_TOKEN),
77
- displayName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
- description: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
77
+ agentName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
+ agentDescription: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
79
79
  timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
80
80
  };
81
81
  }
@@ -5,6 +5,15 @@ export declare class MessengerError extends Error {
5
5
  readonly status: number;
6
6
  constructor(message: string, status?: number);
7
7
  }
8
+ export interface EnsureAgentOptions {
9
+ registered?: boolean;
10
+ /** Identité de l'agent : d'où il vient. Posée par la configuration. */
11
+ agent_name?: string | null;
12
+ agent_description?: string | null;
13
+ /** Nom de la boîte : de quoi cette session s'occupe. Posé par l'agent lui-même. */
14
+ display_name?: string | null;
15
+ description?: string | null;
16
+ }
8
17
  export interface StoreEvents {
9
18
  /** Émis pour chaque destinataire d'un message, y compris la copie « sent » de l'expéditeur. */
10
19
  message: [{
@@ -26,16 +35,21 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
26
35
  constructor(dbPath: string);
27
36
  private migrate;
28
37
  close(): void;
29
- /** Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire. */
30
- ensureAgent(id: string, opts?: {
31
- registered?: boolean;
32
- display_name?: string | null;
33
- description?: string | null;
34
- }): Agent;
38
+ /**
39
+ * Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire.
40
+ * Chaque champ omis (ou null) laisse la valeur en place : l'enregistrement pose l'identité
41
+ * de l'agent sans toucher au nom de la boîte, et `rename_mailbox` fait l'inverse.
42
+ */
43
+ ensureAgent(id: string, opts?: EnsureAgentOptions): Agent;
35
44
  touchAgent(id: string): void;
36
45
  getAgent(id: string): Agent | null;
37
- /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
38
- listAgents(): AgentDirectoryEntry[];
46
+ /**
47
+ * Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien.
48
+ *
49
+ * `exclude` retire une adresse du résultat : l'annuaire servi à un agent ne lui montre pas
50
+ * sa propre boîte, la seule à laquelle il ne peut pas écrire.
51
+ */
52
+ listAgents(exclude?: string): AgentDirectoryEntry[];
39
53
  sendMessage(input: SendMessageInput): SendMessageResult;
40
54
  listMessages(agentId: string, opts?: ListMessagesOptions): MessageSummary[];
41
55
  getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
@@ -103,6 +117,7 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
103
117
  /** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
104
118
  private ownedMessageIds;
105
119
  private assertFolderExists;
120
+ /** Destinataire de chaque message demandé, par identifiant de message. */
106
121
  private recipientsFor;
107
122
  private toSummary;
108
123
  private toMessage;
@@ -52,9 +52,11 @@ export class MessageStore extends EventEmitter {
52
52
  migrate() {
53
53
  this.db.exec(`
54
54
  CREATE TABLE IF NOT EXISTS agents (
55
- id TEXT PRIMARY KEY,
56
- display_name TEXT,
57
- description TEXT,
55
+ id TEXT PRIMARY KEY,
56
+ agent_name TEXT,
57
+ agent_description TEXT,
58
+ display_name TEXT,
59
+ description TEXT,
58
60
  created_at TEXT NOT NULL,
59
61
  last_seen_at TEXT,
60
62
  registered INTEGER NOT NULL DEFAULT 0
@@ -103,6 +105,21 @@ export class MessageStore extends EventEmitter {
103
105
  CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
104
106
  CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
105
107
  `);
108
+ // Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
109
+ // rôles, et venait presque toujours de la configuration (le nom du projet). On le
110
+ // déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
111
+ const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
112
+ if (!agentColumns.includes("agent_name")) {
113
+ this.db.exec(`
114
+ ALTER TABLE agents ADD COLUMN agent_name TEXT;
115
+ ALTER TABLE agents ADD COLUMN agent_description TEXT;
116
+ UPDATE agents
117
+ SET agent_name = display_name,
118
+ agent_description = description,
119
+ display_name = NULL,
120
+ description = NULL;
121
+ `);
122
+ }
106
123
  // Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
107
124
  // titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
108
125
  this.db.exec(`
@@ -117,25 +134,31 @@ export class MessageStore extends EventEmitter {
117
134
  this.db.close();
118
135
  }
119
136
  // --- Agents -------------------------------------------------------------
120
- /** Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire. */
137
+ /**
138
+ * Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire.
139
+ * Chaque champ omis (ou null) laisse la valeur en place : l'enregistrement pose l'identité
140
+ * de l'agent sans toucher au nom de la boîte, et `rename_mailbox` fait l'inverse.
141
+ */
121
142
  ensureAgent(id, opts = {}) {
122
143
  const now = new Date().toISOString();
123
144
  const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
124
145
  if (!existing) {
125
146
  this.db
126
- .prepare(`INSERT INTO agents (id, display_name, description, created_at, last_seen_at, registered)
127
- VALUES (?, ?, ?, ?, ?, ?)`)
128
- .run(id, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
147
+ .prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
149
+ .run(id, opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
129
150
  }
130
151
  else {
131
152
  this.db
132
153
  .prepare(`UPDATE agents
133
- SET display_name = COALESCE(?, display_name),
134
- description = COALESCE(?, description),
135
- registered = MAX(registered, ?),
136
- last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
154
+ SET agent_name = COALESCE(?, agent_name),
155
+ agent_description = COALESCE(?, agent_description),
156
+ display_name = COALESCE(?, display_name),
157
+ description = COALESCE(?, description),
158
+ registered = MAX(registered, ?),
159
+ last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
137
160
  WHERE id = ?`)
138
- .run(opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
161
+ .run(opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
139
162
  }
140
163
  return this.getAgent(id);
141
164
  }
@@ -146,13 +169,20 @@ export class MessageStore extends EventEmitter {
146
169
  const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
147
170
  return row ? { ...row, registered: Boolean(row.registered) } : null;
148
171
  }
149
- /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
150
- listAgents() {
172
+ /**
173
+ * Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien.
174
+ *
175
+ * `exclude` retire une adresse du résultat : l'annuaire servi à un agent ne lui montre pas
176
+ * sa propre boîte, la seule à laquelle il ne peut pas écrire.
177
+ */
178
+ listAgents(exclude) {
179
+ const excluded = exclude ? normalizeAgentId(exclude) : "";
151
180
  const rows = this.db
152
181
  .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
153
182
  FROM agents a
183
+ WHERE a.id != ?
154
184
  ORDER BY last_activity_at DESC, a.id`)
155
- .all();
185
+ .all(excluded);
156
186
  return rows.map((r) => ({
157
187
  ...r,
158
188
  registered: Boolean(r.registered),
@@ -169,11 +199,14 @@ export class MessageStore extends EventEmitter {
169
199
  const from = normalizeAgentId(input.from);
170
200
  if (!from)
171
201
  throw new MessengerError("sender agent id is required");
172
- const to = dedupe(input.to.map(normalizeAgentId).filter(Boolean));
173
- // Un même agent ne peut pas être à la fois destinataire principal et en copie.
174
- const cc = dedupe((input.cc ?? []).map(normalizeAgentId).filter(Boolean)).filter((id) => !to.includes(id));
175
- if (to.length === 0)
176
- throw new MessengerError("at least one recipient is required in `to`");
202
+ // Un message va d'une boîte à une seule autre : pas de diffusion, pas de copie.
203
+ const to = normalizeAgentId(input.to);
204
+ if (!to)
205
+ throw new MessengerError("a recipient is required in `to`");
206
+ // Une boîte ne se parle pas à elle-même : la copie « sent » et la copie « inbox »
207
+ // désigneraient le même agent, et le fil n'aurait qu'un seul participant.
208
+ if (to === from)
209
+ throw new MessengerError("an agent cannot send a message to itself");
177
210
  const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
178
211
  const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
179
212
  let threadId;
@@ -191,18 +224,16 @@ export class MessageStore extends EventEmitter {
191
224
  const messageId = newId("msg");
192
225
  const now = new Date().toISOString();
193
226
  const priority = input.priority ?? "normal";
194
- const unknownRecipients = [...to, ...cc].filter((id) => !this.getAgent(id)?.registered);
227
+ const unknownRecipient = !this.getAgent(to)?.registered;
195
228
  const deliveries = [
196
- ...to.map((agent_id) => ({ agent_id, role: "to", folder: "inbox", read_at: null })),
197
- ...cc.map((agent_id) => ({ agent_id, role: "cc", folder: "inbox", read_at: null })),
229
+ { agent_id: to, role: "to", folder: "inbox", read_at: null },
198
230
  { agent_id: from, role: "sender", folder: "sent", read_at: now },
199
231
  ];
200
232
  this.db.transaction(() => {
201
233
  this.ensureAgent(from);
202
234
  // Écrire est le signe d'activité le plus fiable : l'annuaire s'appuie dessus.
203
235
  this.touchAgent(from);
204
- for (const id of [...to, ...cc])
205
- this.ensureAgent(id);
236
+ this.ensureAgent(to);
206
237
  // Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
207
238
  // n'est touchée que si l'expéditeur fournit un nouveau titre.
208
239
  this.db
@@ -231,8 +262,8 @@ export class MessageStore extends EventEmitter {
231
262
  thread_id: threadId,
232
263
  thread_title: this.getThreadInfo(threadId)?.title ?? null,
233
264
  created_at: now,
234
- delivered_to: [...to, ...cc],
235
- unknown_recipients: unknownRecipients,
265
+ delivered_to: to,
266
+ unknown_recipient: unknownRecipient,
236
267
  };
237
268
  }
238
269
  // --- Lecture ------------------------------------------------------------
@@ -282,7 +313,7 @@ export class MessageStore extends EventEmitter {
282
313
  JOIN messages m ON m.id = d.message_id
283
314
  ${THREAD_JOIN}
284
315
  WHERE d.agent_id = ? AND d.message_id = ?
285
- ORDER BY CASE d.role WHEN 'to' THEN 0 WHEN 'cc' THEN 1 ELSE 2 END
316
+ ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
286
317
  LIMIT 1`)
287
318
  .get(agentId, messageId);
288
319
  if (!row)
@@ -628,6 +659,7 @@ export class MessageStore extends EventEmitter {
628
659
  if (!row)
629
660
  throw new MessengerError(`unknown folder: ${folder}`, 404);
630
661
  }
662
+ /** Destinataire de chaque message demandé, par identifiant de message. */
631
663
  recipientsFor(messageIds) {
632
664
  const map = new Map();
633
665
  const ids = dedupe(messageIds);
@@ -635,29 +667,20 @@ export class MessageStore extends EventEmitter {
635
667
  return map;
636
668
  const placeholders = ids.map(() => "?").join(",");
637
669
  const rows = this.db
638
- .prepare(`SELECT message_id, agent_id, role FROM deliveries
639
- WHERE message_id IN (${placeholders}) AND role != 'sender'
640
- ORDER BY agent_id`)
670
+ .prepare(`SELECT message_id, agent_id FROM deliveries
671
+ WHERE message_id IN (${placeholders}) AND role = 'to'`)
641
672
  .all(...ids);
642
- for (const row of rows) {
643
- const entry = map.get(row.message_id) ?? { to: [], cc: [] };
644
- if (row.role === "to")
645
- entry.to.push(row.agent_id);
646
- else if (row.role === "cc")
647
- entry.cc.push(row.agent_id);
648
- map.set(row.message_id, entry);
649
- }
673
+ for (const row of rows)
674
+ map.set(row.message_id, row.agent_id);
650
675
  return map;
651
676
  }
652
677
  toSummary(row, recipients) {
653
- const rcpt = recipients.get(row.message_id) ?? { to: [], cc: [] };
654
678
  return {
655
679
  message_id: row.message_id,
656
680
  thread_id: row.thread_id,
657
681
  thread_title: row.thread_title,
658
682
  from: row.sender_id,
659
- to: rcpt.to,
660
- cc: rcpt.cc,
683
+ to: recipients.get(row.message_id) ?? "",
661
684
  subject: row.subject,
662
685
  preview: preview(row.body),
663
686
  priority: row.priority,