@imenam/mcp-messenger 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,11 +143,25 @@ n'hérite pas du bloc `env` du `.mcp.json`, donc ni de `MESSENGER_SERVER_URL` ni
143
143
  Le fichier est nommé par hachage de l'adresse, supprimé à la fin de la session, et purgé au
144
144
  bout de sept jours si la session s'est arrêtée brutalement.
145
145
 
146
+ **Savoir si c'est vraiment armé.** L'armement dépend de l'agent : rien ne peut le déclencher
147
+ à sa place, et un agent qui saute l'étape se croirait joignable sans l'être. Le serveur suit
148
+ donc les attentes tenues par un veilleur, et `whoami` le dit :
149
+
150
+ ```
151
+ Notifications: armed — incoming messages will interrupt you (watcher since 2026-09-07T13:31:00.203Z).
152
+ Notifications: NOT armed — no incoming message will reach you. Call enable_notifications.
153
+ ```
154
+
155
+ C'est une présence de connexion, pas une donnée stockée : elle vit en mémoire du serveur et
156
+ disparaît avec lui. Un `wait_for_message` ordinaire ne compte pas — il s'arrête au premier
157
+ message, un veilleur reste. Entre deux attentes longues, un délai de grâce de deux minutes
158
+ évite que le signalement clignote. `enable_notifications` s'en sert aussi : si un veilleur est
159
+ déjà en place, il le dit et n'en fait pas armer un second, qui doublerait chaque notification.
160
+
146
161
  **Ce que ça ne couvre pas.** Le veilleur n'annonce que les messages arrivés **après** son
147
162
  démarrage : `enable_notifications` signale le nombre de non-lus déjà en boîte, à traiter avec
148
- `list_messages`. Et l'armement dépend de l'agent : s'il saute l'étape `Monitor`, il ne sera
149
- jamais notifié et rien côté serveur ne le détecte. C'est pourquoi `--claude-setup-mcp` écrit
150
- un rappel dans le `CLAUDE.md` du projet.
163
+ `list_messages`. Et `--claude-setup-mcp` écrit un rappel d'armement dans le `CLAUDE.md` du
164
+ projet, pour que l'étape ne soit pas oubliée en début de session.
151
165
 
152
166
  **Sur un autre hôte que Claude Code.** `--watch` est une commande ordinaire : n'importe quel
153
167
  superviseur capable de lire les lignes d'un processus peut la brancher. Sans relais, elle lit
@@ -159,7 +173,7 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
159
173
 
160
174
  | Outil | Rôle |
161
175
  |---|---|
162
- | `whoami` | Adresse de cette session, serveur connecté, compteurs par dossier |
176
+ | `whoami` | Adresse de cette session, serveur connecté, veilleur armé ou non, compteurs par dossier |
163
177
  | `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
164
178
  | `set_mailbox_label` | Nommer sa boîte, pour la reconnaître dans l'annuaire et la console |
165
179
  | `list_agents` | Annuaire : qui est joignable, avec quelle description |
@@ -298,7 +312,7 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
298
312
  | `GET` | `/api/agents` | Annuaire des boîtes |
299
313
  | `GET` | `/api/agents/:id` | Une boîte, ou 404 |
300
314
  | `PATCH` | `/api/agents/me` | Renommer la boîte courante |
301
- | `GET` | `/api/mailbox` | Compteurs de la boîte |
315
+ | `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
302
316
  | `POST` | `/api/messages` | Envoyer un message |
303
317
  | `GET` | `/api/messages` | Lister (`folder`, `unread_only`, `from`, `thread_id`, `search`, `limit`, `offset`) |
304
318
  | `GET` | `/api/messages/:id` | Lire (`?mark_read=false` pour ne pas marquer) |
@@ -307,7 +321,7 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
307
321
  | `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
308
322
  | `GET` | `/api/threads/:id` | Fil complet |
309
323
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
310
- | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`) |
324
+ | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
311
325
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` · `/admin/threads/:id` | Supervision, lecture seule |
312
326
 
313
327
  ---
@@ -355,6 +369,7 @@ src/
355
369
  logger.ts # journalisation sur stderr (stdout est réservé au JSON-RPC)
356
370
  core/store.ts # SQLite : agents, messages, livraisons, dossiers
357
371
  server/app.ts # API HTTP, authentification, supervision
372
+ server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
358
373
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
359
374
  server/ui/ # console web (HTML/CSS/JS sans dépendance)
360
375
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
@@ -366,7 +381,7 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
366
381
 
367
382
  ### Tests
368
383
 
369
- 104 tests couvrant : le stockage et l'isolation des boîtes, la validation de configuration,
384
+ 112 tests couvrant : le stockage et l'isolation des boîtes, la validation de configuration,
370
385
  l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
371
386
  agent, jeton de console distinct), les outils MCP de bout en bout (deux agents qui
372
387
  conversent à travers un vrai serveur), l'allocation des boîtes par session — deux processus
@@ -376,7 +391,8 @@ troisième session reprend la boîte de l'une d'elles — et le binaire publié
376
391
  La notification passive est couverte de bout en bout : le relais vers le veilleur (isolation
377
392
  entre boîtes, adresse jamais transformée en chemin, nettoyage), le format d'évènement (une
378
393
  ligne, quel que soit le sujet), et un veilleur réellement lancé comme processus séparé, sans
379
- jeton dans son environnement, qui émet sa ligne à l'arrivée d'un message.
394
+ jeton dans son environnement, qui émet sa ligne à l'arrivée d'un message — et `whoami` qui
395
+ passe de « NOT armed » à « armed » quand ce veilleur prend son poste.
380
396
 
381
397
  ---
382
398
 
@@ -63,7 +63,7 @@ export async function runWatchMode(mailbox) {
63
63
  let backoff = BACKOFF_MIN_MS;
64
64
  for (;;) {
65
65
  try {
66
- const result = await client.waitForMessage(POLL_SECONDS);
66
+ const result = await client.waitForMessage(POLL_SECONDS, undefined, { watcher: true });
67
67
  backoff = BACKOFF_MIN_MS;
68
68
  if (result.timed_out || !result.message)
69
69
  continue;
@@ -80,6 +80,13 @@ export declare class MessengerClient {
80
80
  listFolders(): Promise<FolderInfo[]>;
81
81
  createFolder(name: string): Promise<FolderInfo>;
82
82
  deleteFolder(name: string): Promise<number>;
83
- waitForMessage(timeoutSeconds: number, folder?: string): Promise<WaitResult>;
83
+ /**
84
+ * `watcher` signale une attente tenue par un veilleur `--watch`, et non par un
85
+ * `wait_for_message` ponctuel : c'est ce qui permet au serveur de dire si la boîte est
86
+ * réellement surveillée.
87
+ */
88
+ waitForMessage(timeoutSeconds: number, folder?: string, options?: {
89
+ watcher?: boolean;
90
+ }): Promise<WaitResult>;
84
91
  private request;
85
92
  }
@@ -139,10 +139,15 @@ export class MessengerClient {
139
139
  const { moved } = await this.request("DELETE", `/api/folders/${encodeURIComponent(name)}`);
140
140
  return moved;
141
141
  }
142
- async waitForMessage(timeoutSeconds, folder) {
142
+ /**
143
+ * `watcher` signale une attente tenue par un veilleur `--watch`, et non par un
144
+ * `wait_for_message` ponctuel : c'est ce qui permet au serveur de dire si la boîte est
145
+ * réellement surveillée.
146
+ */
147
+ async waitForMessage(timeoutSeconds, folder, options = {}) {
143
148
  // L'attente longue peut dépasser le délai HTTP par défaut : on l'étend d'une marge.
144
149
  return this.request("GET", "/api/wait", {
145
- query: { timeout_seconds: timeoutSeconds, folder },
150
+ query: { timeout_seconds: timeoutSeconds, folder, watcher: options.watcher ? "true" : undefined },
146
151
  timeoutMs: timeoutSeconds * 1000 + this.config.timeoutMs,
147
152
  });
148
153
  }
@@ -67,8 +67,23 @@ export interface ListMessagesOptions {
67
67
  limit?: number;
68
68
  offset?: number;
69
69
  }
70
+ /**
71
+ * Présence d'un veilleur (`--watch`) sur une boîte, telle que la voit le serveur.
72
+ *
73
+ * `armed: false` veut dire que personne n'écoute : les messages arriveront en boîte,
74
+ * mais aucun agent n'en sera averti tant qu'il ne regarde pas de lui-même.
75
+ */
76
+ export interface WatcherStatus {
77
+ armed: boolean;
78
+ /** Depuis quand ce veilleur est en place, ou null s'il n'y en a pas. */
79
+ since: string | null;
80
+ /** Dernière attente observée, y compris pour un veilleur qui vient de disparaître. */
81
+ last_seen: string | null;
82
+ }
70
83
  export interface MailboxStats {
71
84
  agent_id: string;
72
85
  unread_total: number;
73
86
  folders: FolderInfo[];
87
+ /** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
88
+ watcher?: WatcherStatus;
74
89
  }
@@ -240,7 +240,12 @@ export async function callTool(client, name, rawArgs) {
240
240
  if (client.isAttachedElsewhere) {
241
241
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
242
242
  }
243
- lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`, `Unread (excluding trash): ${stats.unread_total}`, "Folders:", ...folders);
243
+ lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
244
+ // Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
245
+ // Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
246
+ stats.watcher?.armed
247
+ ? `Notifications: armed — incoming messages will interrupt you (watcher since ${stats.watcher.since}).`
248
+ : "Notifications: NOT armed — no incoming message will reach you. Call enable_notifications.", `Unread (excluding trash): ${stats.unread_total}`, "Folders:", ...folders);
244
249
  return lines.join("\n");
245
250
  }
246
251
  case "attach_to_mailbox": {
@@ -371,6 +376,15 @@ export async function callTool(client, name, rawArgs) {
371
376
  writeHandoff({ mailbox, serverUrl: client.serverUrl, token: client.tokenForHandoff });
372
377
  const stats = await client.stats().catch(() => null);
373
378
  const pending = stats?.folders.find((f) => f.name === "inbox")?.unread ?? 0;
379
+ // Un veilleur déjà en place suffit : en armer un second doublerait chaque
380
+ // notification sans rien apporter.
381
+ if (stats?.watcher?.armed) {
382
+ return [
383
+ `Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
384
+ "Nothing to do: keep working, incoming messages will interrupt you on their own.",
385
+ "Do not arm a second watcher — you would get every message twice.",
386
+ ].join("\n");
387
+ }
374
388
  const lines = [
375
389
  `Notifications enabled for mailbox \`${mailbox}\`.`,
376
390
  "",
@@ -8,6 +8,7 @@ import { rateLimit } from "express-rate-limit";
8
8
  import { z } from "zod";
9
9
  import { MessengerError, normalizeAgentId } from "../core/store.js";
10
10
  import { PRIORITIES } from "../core/types.js";
11
+ import { WatcherRegistry } from "./watchers.js";
11
12
  import { APP_VERSION } from "../version.js";
12
13
  import { logger } from "../logger.js";
13
14
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
@@ -51,6 +52,9 @@ const folderSchema = z.object({ name: z.string().trim().min(1).max(40) });
51
52
  const waitQuerySchema = z.object({
52
53
  timeout_seconds: z.coerce.number().int().min(1).optional(),
53
54
  folder: z.string().trim().min(1).optional(),
55
+ // Distingue le veilleur d'un `wait_for_message` ordinaire : les deux tiennent la même
56
+ // attente longue, mais seul le premier prouve que l'agent est joignable en continu.
57
+ watcher: z.enum(["true", "1"]).optional(),
54
58
  });
55
59
  const adminListSchema = z.object({
56
60
  search: z.string().trim().min(1).optional(),
@@ -61,6 +65,8 @@ const adminListSchema = z.object({
61
65
  });
62
66
  export function createApp({ store, config }) {
63
67
  const app = express();
68
+ // Présence des veilleurs : état de connexion, donc en mémoire du processus.
69
+ const watchers = new WatcherRegistry();
64
70
  app.disable("x-powered-by");
65
71
  app.set("trust proxy", false);
66
72
  app.use(helmet({
@@ -136,7 +142,7 @@ export function createApp({ store, config }) {
136
142
  res.json({ agent });
137
143
  });
138
144
  api.get("/mailbox", (req, res) => {
139
- res.json(store.stats(req.agentId));
145
+ res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
140
146
  });
141
147
  api.post("/messages", (req, res) => {
142
148
  const input = sendSchema.parse(req.body ?? {});
@@ -190,16 +196,20 @@ export function createApp({ store, config }) {
190
196
  // l'appelant ; les non-lus déjà présents sont ignorés (sinon l'outil rendrait la main
191
197
  // immédiatement tant que la boîte n'est pas vidée).
192
198
  api.get("/wait", (req, res) => {
193
- const { timeout_seconds, folder } = waitQuerySchema.parse(req.query);
199
+ const { timeout_seconds, folder, watcher } = waitQuerySchema.parse(req.query);
194
200
  const agentId = req.agentId;
195
201
  const timeoutSeconds = Math.min(timeout_seconds ?? 30, config.maxWaitSeconds);
196
202
  if (folder && !store.listFolders(agentId).some((f) => f.name === folder)) {
197
203
  throw new MessengerError(`unknown folder: ${folder}`, 404);
198
204
  }
205
+ // Le relâchement doit couvrir les trois sorties possibles : message, expiration,
206
+ // client parti. Un compteur qui ne redescend pas ferait croire à un veilleur éternel.
207
+ const releaseWatcher = watcher ? watchers.begin(agentId) : () => { };
199
208
  const done = (payload) => {
200
209
  clearTimeout(timer);
201
210
  store.off("message", onMessage);
202
211
  req.off("close", onClose);
212
+ releaseWatcher();
203
213
  if (!res.headersSent)
204
214
  res.json(payload);
205
215
  };
@@ -215,6 +225,7 @@ export function createApp({ store, config }) {
215
225
  const onClose = () => {
216
226
  clearTimeout(timer);
217
227
  store.off("message", onMessage);
228
+ releaseWatcher();
218
229
  };
219
230
  const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
220
231
  store.on("message", onMessage);
@@ -0,0 +1,10 @@
1
+ import type { WatcherStatus } from "../core/types.js";
2
+ export declare class WatcherRegistry {
3
+ private readonly now;
4
+ private readonly presence;
5
+ /** L'horloge est injectable : le délai de grâce se teste sans faire attendre la suite. */
6
+ constructor(now?: () => number);
7
+ /** Déclare une attente de veilleur. La fonction rendue la clôt, et doit toujours être appelée. */
8
+ begin(agentId: string): () => void;
9
+ status(agentId: string): WatcherStatus;
10
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Présence des veilleurs, par boîte.
3
+ *
4
+ * L'armement du veilleur dépend de l'agent : rien ne peut le déclencher à sa place, et
5
+ * un agent qui saute l'étape se croit joignable sans l'être. Ce registre rend le fait
6
+ * observable — `whoami` peut alors répondre « armé » ou « pas armé » plutôt que rien.
7
+ *
8
+ * État de connexion, donc volatile : il vit en mémoire du processus serveur et disparaît
9
+ * avec lui. Le mettre en base ferait croire à un veilleur qui n'existe plus.
10
+ */
11
+ /**
12
+ * Un veilleur enchaîne des attentes longues ; entre deux, il n'est plus connecté pendant
13
+ * quelques millisecondes. Ce délai de grâce évite de le déclarer absent dans cet
14
+ * intervalle, ou juste après qu'un message a mis fin à son attente.
15
+ */
16
+ const GRACE_MS = 2 * 60 * 1000;
17
+ export class WatcherRegistry {
18
+ now;
19
+ presence = new Map();
20
+ /** L'horloge est injectable : le délai de grâce se teste sans faire attendre la suite. */
21
+ constructor(now = Date.now) {
22
+ this.now = now;
23
+ }
24
+ /** Déclare une attente de veilleur. La fonction rendue la clôt, et doit toujours être appelée. */
25
+ begin(agentId) {
26
+ const now = this.now();
27
+ const current = this.presence.get(agentId);
28
+ // Une absence dépassant le délai de grâce commence une nouvelle série : `since`
29
+ // désigne le moment où ce veilleur-ci s'est armé, pas un ancêtre oublié.
30
+ const continuing = current && now - current.lastSeen <= GRACE_MS;
31
+ const entry = continuing
32
+ ? { ...current, inflight: current.inflight + 1, lastSeen: now }
33
+ : { inflight: 1, since: now, lastSeen: now };
34
+ this.presence.set(agentId, entry);
35
+ let released = false;
36
+ return () => {
37
+ if (released)
38
+ return;
39
+ released = true;
40
+ const active = this.presence.get(agentId);
41
+ if (!active)
42
+ return;
43
+ // On garde l'entrée après la dernière attente : c'est elle qui porte le délai de
44
+ // grâce, le temps que le veilleur relance son tour suivant.
45
+ this.presence.set(agentId, {
46
+ ...active,
47
+ inflight: Math.max(0, active.inflight - 1),
48
+ lastSeen: this.now(),
49
+ });
50
+ };
51
+ }
52
+ status(agentId) {
53
+ const now = this.now();
54
+ const entry = this.presence.get(agentId);
55
+ if (!entry)
56
+ return { armed: false, since: null, last_seen: null };
57
+ const armed = entry.inflight > 0 || now - entry.lastSeen <= GRACE_MS;
58
+ if (!armed) {
59
+ // Purge à la lecture : sans veilleur pour cette boîte, l'entrée n'a plus d'objet.
60
+ this.presence.delete(agentId);
61
+ return { armed: false, since: null, last_seen: new Date(entry.lastSeen).toISOString() };
62
+ }
63
+ return {
64
+ armed: true,
65
+ since: new Date(entry.since).toISOString(),
66
+ last_seen: new Date(entry.lastSeen).toISOString(),
67
+ };
68
+ }
69
+ }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.3";
1
+ export declare const APP_VERSION = "1.0.4";
@@ -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.3";
2
+ export const APP_VERSION = "1.0.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",