@imenam/mcp-messenger 1.0.3 → 1.0.5

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.
@@ -15,6 +15,15 @@ export interface Agent {
15
15
  /** false pour un agent créé implicitement parce qu'un message lui a été adressé. */
16
16
  registered: boolean;
17
17
  }
18
+ /**
19
+ * Entrée d'annuaire : la fiche de l'agent, plus le moment où sa boîte a vécu pour la
20
+ * dernière fois. C'est ce moment qui classe l'annuaire, pas l'identifiant : une adresse
21
+ * opaque ne dit rien à qui lit la console.
22
+ */
23
+ export interface AgentDirectoryEntry extends Agent {
24
+ /** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
25
+ last_activity_at: string | null;
26
+ }
18
27
  export interface SendMessageInput {
19
28
  from: string;
20
29
  to: string[];
@@ -24,10 +33,39 @@ export interface SendMessageInput {
24
33
  priority?: Priority;
25
34
  /** Id d'un message existant : la réponse hérite de son fil de discussion. */
26
35
  reply_to?: string | null;
36
+ /** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
37
+ thread_title?: string | null;
38
+ thread_description?: string | null;
39
+ }
40
+ /**
41
+ * Intitulé d'un fil de discussion.
42
+ *
43
+ * Le titre est unique et partagé : tous les participants parlent du même sujet, ils
44
+ * doivent donc lire le même nom. Le dernier qui renomme a le dernier mot ; `named_by`
45
+ * garde la trace de qui c'était.
46
+ */
47
+ export interface ThreadInfo {
48
+ thread_id: string;
49
+ title: string | null;
50
+ description: string | null;
51
+ /** Agent ayant posé le nom courant, ou null tant que le fil n'a pas été nommé. */
52
+ named_by: string | null;
53
+ created_at: string;
54
+ updated_at: string | null;
55
+ }
56
+ export interface ThreadSummary extends ThreadInfo {
57
+ /** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
58
+ subject: string;
59
+ messages: number;
60
+ unread: number;
61
+ participants: string[];
62
+ last_message_at: string;
27
63
  }
28
64
  export interface SendMessageResult {
29
65
  message_id: string;
30
66
  thread_id: string;
67
+ /** Titre du fil après envoi, null s'il n'a jamais été nommé. */
68
+ thread_title: string | null;
31
69
  created_at: string;
32
70
  delivered_to: string[];
33
71
  /** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
@@ -36,6 +74,8 @@ export interface SendMessageResult {
36
74
  export interface MessageSummary {
37
75
  message_id: string;
38
76
  thread_id: string;
77
+ /** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
78
+ thread_title: string | null;
39
79
  from: string;
40
80
  to: string[];
41
81
  cc: string[];
@@ -58,6 +98,14 @@ export interface FolderInfo {
58
98
  total: number;
59
99
  unread: number;
60
100
  }
101
+ export interface ListThreadsOptions {
102
+ /** Ne garder que les fils ayant au moins un message non lu. */
103
+ unread_only?: boolean;
104
+ /** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
105
+ search?: string;
106
+ limit?: number;
107
+ offset?: number;
108
+ }
61
109
  export interface ListMessagesOptions {
62
110
  folder?: string;
63
111
  unread_only?: boolean;
@@ -67,8 +115,23 @@ export interface ListMessagesOptions {
67
115
  limit?: number;
68
116
  offset?: number;
69
117
  }
118
+ /**
119
+ * Présence d'un veilleur (`--watch`) sur une boîte, telle que la voit le serveur.
120
+ *
121
+ * `armed: false` veut dire que personne n'écoute : les messages arriveront en boîte,
122
+ * mais aucun agent n'en sera averti tant qu'il ne regarde pas de lui-même.
123
+ */
124
+ export interface WatcherStatus {
125
+ armed: boolean;
126
+ /** Depuis quand ce veilleur est en place, ou null s'il n'y en a pas. */
127
+ since: string | null;
128
+ /** Dernière attente observée, y compris pour un veilleur qui vient de disparaître. */
129
+ last_seen: string | null;
130
+ }
70
131
  export interface MailboxStats {
71
132
  agent_id: string;
72
133
  unread_total: number;
73
134
  folders: FolderInfo[];
135
+ /** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
136
+ watcher?: WatcherStatus;
74
137
  }
@@ -25,21 +25,37 @@ export const TOOLS = [
25
25
  },
26
26
  },
27
27
  {
28
- name: "set_mailbox_label",
29
- description: "Give this mailbox a human-readable label and description, so the user can recognise it in the directory and in the web console. Addresses are opaque: a label is what makes a mailbox findable later.",
28
+ name: "set_label",
29
+ description: "Give a plain-language name to this mailbox, or to a thread. Ids are opaque: a name is what makes a mailbox or a conversation recognisable later, both for the other agents and for the user reading the web console. Without thread_id it names this mailbox ('Développement des tests unitaires du parser'); with thread_id it names that thread ('Le test échoue sur un bug applicatif'). A thread has a single shared name: renaming it changes it for every participant, and the last caller wins.",
30
30
  inputSchema: {
31
31
  type: "object",
32
32
  properties: {
33
- label: { type: "string", description: "Short name, e.g. 'Refactor du parser'." },
34
- description: { type: "string", description: "What this session is working on." },
33
+ label: { type: "string", description: "Short name, one line. Say what the work is about, not who does it." },
34
+ description: { type: "string", description: "Optional longer explanation of the mailbox or the thread." },
35
+ thread_id: {
36
+ type: "string",
37
+ description: "Thread to name. Omit to name this mailbox instead. You must be a participant of the thread.",
38
+ },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ {
44
+ name: "list_threads",
45
+ description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with set_label.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
50
+ search: { type: "string", description: "Free text matched against thread name, description and subject." },
51
+ limit: { type: "number", description: "1-100, defaults to 25." },
35
52
  },
36
- required: ["label"],
37
53
  additionalProperties: false,
38
54
  },
39
55
  },
40
56
  {
41
57
  name: "list_agents",
42
- description: "List the agents known to the mailbox server, with their description and last activity. Use it to find the address of the agent you want to write to.",
58
+ description: "List the agents known to the mailbox server, most recently active first, with their name and description. Use it to find the address of the agent you want to write to.",
43
59
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
44
60
  },
45
61
  {
@@ -59,6 +75,11 @@ export const TOOLS = [
59
75
  body: { type: "string", description: "Message body, plain text or markdown." },
60
76
  priority: { type: "string", enum: [...PRIORITIES], description: "Defaults to normal." },
61
77
  reply_to: { type: "string", description: "Id of the message being answered; the reply joins its thread." },
78
+ thread_title: {
79
+ type: "string",
80
+ description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone, which is what you want when the discussion has drifted.",
81
+ },
82
+ thread_description: { type: "string", description: "Optional longer description of the thread." },
62
83
  },
63
84
  required: ["to", "subject", "body"],
64
85
  additionalProperties: false,
@@ -195,6 +216,8 @@ const schemas = {
195
216
  body: z.string().min(1),
196
217
  priority: z.enum(PRIORITIES).optional(),
197
218
  reply_to: z.string().optional(),
219
+ thread_title: z.string().trim().min(1).max(200).optional(),
220
+ thread_description: z.string().trim().min(1).max(2000).optional(),
198
221
  }),
199
222
  list_messages: z.object({
200
223
  folder: z.string().optional(),
@@ -215,7 +238,20 @@ const schemas = {
215
238
  move_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), folder: z.string().min(1) }),
216
239
  delete_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), permanent: z.boolean().optional() }),
217
240
  attach_to_mailbox: z.object({ mailbox_id: z.string().trim().min(1).optional() }),
218
- set_mailbox_label: z.object({ label: z.string().trim().min(1).max(200), description: z.string().max(2000).optional() }),
241
+ set_label: z
242
+ .object({
243
+ label: z.string().trim().min(1).max(200).optional(),
244
+ description: z.string().trim().min(1).max(2000).optional(),
245
+ thread_id: z.string().trim().min(1).optional(),
246
+ })
247
+ .refine((v) => v.label !== undefined || v.description !== undefined, {
248
+ message: "provide `label` and/or `description`",
249
+ }),
250
+ list_threads: z.object({
251
+ unread_only: z.boolean().optional(),
252
+ search: z.string().trim().min(1).optional(),
253
+ limit: z.number().int().optional(),
254
+ }),
219
255
  create_folder: z.object({ name: z.string().min(1) }),
220
256
  delete_folder: z.object({ name: z.string().min(1) }),
221
257
  wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
@@ -233,14 +269,23 @@ export async function callTool(client, name, rawArgs) {
233
269
  const stats = await client.stats();
234
270
  const health = await client.health().catch(() => null);
235
271
  const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
272
+ const self = await client.getAgent(client.agentId).catch(() => null);
236
273
  const lines = [
237
274
  `Mailbox address: ${client.agentId}`,
238
275
  " ↳ give this address to another agent so it can write to you.",
276
+ self?.display_name
277
+ ? `Mailbox name: ${self.display_name}`
278
+ : "Mailbox name: none — call set_label so the user can tell this session apart from the others.",
239
279
  ];
240
280
  if (client.isAttachedElsewhere) {
241
281
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
242
282
  }
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);
283
+ lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
284
+ // Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
285
+ // Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
286
+ stats.watcher?.armed
287
+ ? `Notifications: armed — incoming messages will interrupt you (watcher since ${stats.watcher.since}).`
288
+ : "Notifications: NOT armed — no incoming message will reach you. Call enable_notifications.", `Unread (excluding trash): ${stats.unread_total}`, "Folders:", ...folders);
244
289
  return lines.join("\n");
245
290
  }
246
291
  case "attach_to_mailbox": {
@@ -268,10 +313,36 @@ export async function callTool(client, name, rawArgs) {
268
313
  .filter(Boolean)
269
314
  .join("\n");
270
315
  }
271
- case "set_mailbox_label": {
272
- const input = schemas.set_mailbox_label.parse(args);
273
- const agent = await client.updateProfile({ display_name: input.label, description: input.description ?? null });
274
- return `Mailbox ${agent.id} labelled "${agent.display_name}".`;
316
+ // `set_mailbox_label` n'est plus publié, mais reste accepté : une session en cours
317
+ // peut l'avoir appris avant la mise à jour du serveur.
318
+ case "set_mailbox_label":
319
+ case "set_label": {
320
+ const input = schemas.set_label.parse(args);
321
+ if (input.thread_id) {
322
+ const thread = await client.setThreadLabel(input.thread_id, {
323
+ title: input.label ?? null,
324
+ description: input.description ?? null,
325
+ });
326
+ return [
327
+ `Thread ${thread.thread_id} is now named "${thread.title}".`,
328
+ thread.description ? `Description: ${thread.description}` : null,
329
+ "Every participant of the thread sees this name.",
330
+ ]
331
+ .filter(Boolean)
332
+ .join("\n");
333
+ }
334
+ const agent = await client.updateProfile({
335
+ display_name: input.label ?? null,
336
+ description: input.description ?? null,
337
+ });
338
+ return `Mailbox ${agent.id} named "${agent.display_name}".`;
339
+ }
340
+ case "list_threads": {
341
+ const input = schemas.list_threads.parse(args);
342
+ const threads = await client.listThreads(input);
343
+ if (threads.length === 0)
344
+ return "No thread in this mailbox matching these filters.";
345
+ return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
275
346
  }
276
347
  case "list_agents": {
277
348
  const agents = await client.listAgents();
@@ -283,7 +354,8 @@ export async function callTool(client, name, rawArgs) {
283
354
  .filter(Boolean)
284
355
  .join(", ");
285
356
  const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
286
- const seen = agent.last_seen_at ? `last seen ${agent.last_seen_at}` : "never seen";
357
+ // L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
358
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
287
359
  return [`• ${title}${flags ? ` (${flags})` : ""}`, ` ${agent.description ?? "no description"}`, ` ${seen}`].join("\n");
288
360
  })
289
361
  .join("\n");
@@ -297,12 +369,17 @@ export async function callTool(client, name, rawArgs) {
297
369
  body: input.body,
298
370
  priority: input.priority,
299
371
  reply_to: input.reply_to ?? null,
372
+ thread_title: input.thread_title ?? null,
373
+ thread_description: input.thread_description ?? null,
300
374
  });
301
375
  const lines = [
302
376
  `Message sent: ${result.message_id}`,
303
- `Thread: ${result.thread_id}`,
377
+ `Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
304
378
  `Delivered to: ${result.delivered_to.join(", ")}`,
305
379
  ];
380
+ if (!result.thread_title && !input.reply_to) {
381
+ lines.push("This thread has no name yet — set_label with its thread_id makes it readable for everyone.");
382
+ }
306
383
  if (result.unknown_recipients.length > 0) {
307
384
  lines.push(`Warning — never connected to this server, the message will wait in their mailbox: ${result.unknown_recipients.join(", ")}`);
308
385
  }
@@ -323,8 +400,14 @@ export async function callTool(client, name, rawArgs) {
323
400
  }
324
401
  case "get_thread": {
325
402
  const input = schemas.get_thread.parse(args);
326
- const messages = await client.getThread(input.thread_id);
327
- return [`Thread ${input.thread_id} — ${messages.length} message(s):`, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
403
+ const { thread, messages } = await client.getThread(input.thread_id);
404
+ const header = [
405
+ `Thread ${formatThreadRef(thread?.title ?? null, input.thread_id)} — ${messages.length} message(s):`,
406
+ thread?.description ? `Description: ${thread.description}` : null,
407
+ ]
408
+ .filter(Boolean)
409
+ .join("\n");
410
+ return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
328
411
  }
329
412
  case "mark_messages": {
330
413
  const input = schemas.mark_messages.parse(args);
@@ -371,6 +454,15 @@ export async function callTool(client, name, rawArgs) {
371
454
  writeHandoff({ mailbox, serverUrl: client.serverUrl, token: client.tokenForHandoff });
372
455
  const stats = await client.stats().catch(() => null);
373
456
  const pending = stats?.folders.find((f) => f.name === "inbox")?.unread ?? 0;
457
+ // Un veilleur déjà en place suffit : en armer un second doublerait chaque
458
+ // notification sans rien apporter.
459
+ if (stats?.watcher?.armed) {
460
+ return [
461
+ `Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
462
+ "Nothing to do: keep working, incoming messages will interrupt you on their own.",
463
+ "Do not arm a second watcher — you would get every message twice.",
464
+ ].join("\n");
465
+ }
374
466
  const lines = [
375
467
  `Notifications enabled for mailbox \`${mailbox}\`.`,
376
468
  "",
@@ -410,6 +502,22 @@ function asArray(value) {
410
502
  return [];
411
503
  return Array.isArray(value) ? value : [value];
412
504
  }
505
+ /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
506
+ function formatThreadRef(title, threadId) {
507
+ return title ? `${title} (${threadId})` : threadId;
508
+ }
509
+ function formatThread(thread) {
510
+ // Sans nom, le sujet d'ouverture reste le seul repère : mieux vaut l'afficher que l'id nu.
511
+ const name = thread.title ?? `${thread.subject} (unnamed)`;
512
+ return [
513
+ `${thread.unread > 0 ? "•" : " "} ${name}`,
514
+ thread.description ? ` ${thread.description}` : null,
515
+ ` ${thread.messages} message(s), ${thread.unread} unread · with ${thread.participants.join(", ")}`,
516
+ ` last activity ${thread.last_message_at} · thread ${thread.thread_id}`,
517
+ ]
518
+ .filter(Boolean)
519
+ .join("\n");
520
+ }
413
521
  function formatSummary(message) {
414
522
  const flags = [message.read ? " " : "•", message.starred ? "★" : " "].join("");
415
523
  const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
@@ -417,7 +525,7 @@ function formatSummary(message) {
417
525
  `${flags} ${message.subject}${priority}`,
418
526
  ` from ${message.from} to ${message.to.join(", ") || "—"}${message.cc.length ? ` (cc ${message.cc.join(", ")})` : ""} · ${message.created_at}`,
419
527
  ` ${message.preview}`,
420
- ` id ${message.message_id} · thread ${message.thread_id}`,
528
+ ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
421
529
  ].join("\n");
422
530
  }
423
531
  function formatMessage(message) {
@@ -429,7 +537,7 @@ function formatMessage(message) {
429
537
  `Date: ${message.created_at}`,
430
538
  `Priority: ${message.priority}`,
431
539
  `Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
432
- `Id: ${message.message_id} · Thread: ${message.thread_id}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
540
+ `Id: ${message.message_id} · Thread: ${formatThreadRef(message.thread_title, message.thread_id)}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
433
541
  ]
434
542
  .filter(Boolean)
435
543
  .join("\n");
@@ -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");
@@ -26,6 +27,22 @@ const sendSchema = z.object({
26
27
  body: z.string().min(1).max(200_000),
27
28
  priority: z.enum(PRIORITIES).optional(),
28
29
  reply_to: z.string().trim().min(1).nullish(),
30
+ thread_title: z.string().trim().min(1).max(200).nullish(),
31
+ thread_description: z.string().trim().min(1).max(2000).nullish(),
32
+ });
33
+ const threadLabelSchema = z
34
+ .object({
35
+ title: z.string().trim().min(1).max(200).nullish(),
36
+ description: z.string().trim().min(1).max(2000).nullish(),
37
+ })
38
+ .refine((v) => v.title != null || v.description != null, {
39
+ message: "provide `title` and/or `description`",
40
+ });
41
+ const listThreadsQuerySchema = z.object({
42
+ unread_only: z.coerce.boolean().optional(),
43
+ search: z.string().trim().min(1).optional(),
44
+ limit: z.coerce.number().int().min(1).max(100).optional(),
45
+ offset: z.coerce.number().int().min(0).optional(),
29
46
  });
30
47
  const listQuerySchema = z.object({
31
48
  folder: z.string().trim().min(1).optional(),
@@ -51,6 +68,9 @@ const folderSchema = z.object({ name: z.string().trim().min(1).max(40) });
51
68
  const waitQuerySchema = z.object({
52
69
  timeout_seconds: z.coerce.number().int().min(1).optional(),
53
70
  folder: z.string().trim().min(1).optional(),
71
+ // Distingue le veilleur d'un `wait_for_message` ordinaire : les deux tiennent la même
72
+ // attente longue, mais seul le premier prouve que l'agent est joignable en continu.
73
+ watcher: z.enum(["true", "1"]).optional(),
54
74
  });
55
75
  const adminListSchema = z.object({
56
76
  search: z.string().trim().min(1).optional(),
@@ -61,6 +81,8 @@ const adminListSchema = z.object({
61
81
  });
62
82
  export function createApp({ store, config }) {
63
83
  const app = express();
84
+ // Présence des veilleurs : état de connexion, donc en mémoire du processus.
85
+ const watchers = new WatcherRegistry();
64
86
  app.disable("x-powered-by");
65
87
  app.set("trust proxy", false);
66
88
  app.use(helmet({
@@ -136,7 +158,7 @@ export function createApp({ store, config }) {
136
158
  res.json({ agent });
137
159
  });
138
160
  api.get("/mailbox", (req, res) => {
139
- res.json(store.stats(req.agentId));
161
+ res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
140
162
  });
141
163
  api.post("/messages", (req, res) => {
142
164
  const input = sendSchema.parse(req.body ?? {});
@@ -148,6 +170,8 @@ export function createApp({ store, config }) {
148
170
  body: input.body,
149
171
  priority: input.priority,
150
172
  reply_to: input.reply_to ?? null,
173
+ thread_title: input.thread_title ?? null,
174
+ thread_description: input.thread_description ?? null,
151
175
  });
152
176
  logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
153
177
  res.status(201).json(result);
@@ -173,8 +197,18 @@ export function createApp({ store, config }) {
173
197
  const markRead = req.query.mark_read !== "false";
174
198
  res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
175
199
  });
200
+ api.get("/threads", (req, res) => {
201
+ const query = listThreadsQuerySchema.parse(req.query);
202
+ res.json({ threads: store.listThreads(req.agentId, query) });
203
+ });
176
204
  api.get("/threads/:id", (req, res) => {
177
- res.json({ thread_id: req.params.id, messages: store.getThread(req.agentId, String(req.params.id)) });
205
+ const threadId = String(req.params.id);
206
+ const messages = store.getThread(req.agentId, threadId);
207
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
208
+ });
209
+ api.patch("/threads/:id", (req, res) => {
210
+ const input = threadLabelSchema.parse(req.body ?? {});
211
+ res.json({ thread: store.setThreadLabel(req.agentId, String(req.params.id), input) });
178
212
  });
179
213
  api.get("/folders", (req, res) => {
180
214
  res.json({ folders: store.listFolders(req.agentId) });
@@ -190,16 +224,20 @@ export function createApp({ store, config }) {
190
224
  // l'appelant ; les non-lus déjà présents sont ignorés (sinon l'outil rendrait la main
191
225
  // immédiatement tant que la boîte n'est pas vidée).
192
226
  api.get("/wait", (req, res) => {
193
- const { timeout_seconds, folder } = waitQuerySchema.parse(req.query);
227
+ const { timeout_seconds, folder, watcher } = waitQuerySchema.parse(req.query);
194
228
  const agentId = req.agentId;
195
229
  const timeoutSeconds = Math.min(timeout_seconds ?? 30, config.maxWaitSeconds);
196
230
  if (folder && !store.listFolders(agentId).some((f) => f.name === folder)) {
197
231
  throw new MessengerError(`unknown folder: ${folder}`, 404);
198
232
  }
233
+ // Le relâchement doit couvrir les trois sorties possibles : message, expiration,
234
+ // client parti. Un compteur qui ne redescend pas ferait croire à un veilleur éternel.
235
+ const releaseWatcher = watcher ? watchers.begin(agentId) : () => { };
199
236
  const done = (payload) => {
200
237
  clearTimeout(timer);
201
238
  store.off("message", onMessage);
202
239
  req.off("close", onClose);
240
+ releaseWatcher();
203
241
  if (!res.headersSent)
204
242
  res.json(payload);
205
243
  };
@@ -215,6 +253,7 @@ export function createApp({ store, config }) {
215
253
  const onClose = () => {
216
254
  clearTimeout(timer);
217
255
  store.off("message", onMessage);
256
+ releaseWatcher();
218
257
  };
219
258
  const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
220
259
  store.on("message", onMessage);
@@ -235,10 +274,11 @@ export function createApp({ store, config }) {
235
274
  res.json(store.getMessageAdmin(String(req.params.id)));
236
275
  });
237
276
  admin.get("/threads/:id", (req, res) => {
238
- const messages = store.listAllMessages({ thread_id: String(req.params.id), limit: 100 });
277
+ const threadId = String(req.params.id);
278
+ const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
239
279
  if (messages.length === 0)
240
- throw new MessengerError(`thread not found: ${req.params.id}`, 404);
241
- res.json({ thread_id: req.params.id, messages: messages.slice().reverse() });
280
+ throw new MessengerError(`thread not found: ${threadId}`, 404);
281
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
242
282
  });
243
283
  app.use("/admin", admin);
244
284
  // --- Interface web --------------------------------------------------------
@@ -14,6 +14,7 @@ const state = {
14
14
  search: "",
15
15
  selectedMessageId: null,
16
16
  threadId: null,
17
+ threadTitle: null,
17
18
  };
18
19
 
19
20
  const el = {
@@ -71,6 +72,12 @@ function formatDate(iso) {
71
72
  return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
72
73
  }
73
74
 
75
+ /** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
76
+ function setThreadFilter(threadId, title) {
77
+ state.threadId = threadId;
78
+ state.threadTitle = title;
79
+ }
80
+
74
81
  function renderAgents(overview) {
75
82
  el.serverStats.textContent =
76
83
  overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
@@ -79,14 +86,20 @@ function renderAgents(overview) {
79
86
 
80
87
  for (const agent of overview.agents) {
81
88
  const item = node("li", state.agent === agent.id ? "selected" : "");
82
- item.appendChild(node("div", "id", agent.display_name ? agent.id + " — " + agent.display_name : agent.id));
89
+ // Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
90
+ item.appendChild(node("div", "name", agent.display_name || "sans nom"));
91
+ item.appendChild(node("div", "id", agent.id));
83
92
  item.appendChild(
84
93
  node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
85
94
  );
95
+ // La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
96
+ item.appendChild(
97
+ node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
98
+ );
86
99
  if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
87
100
  item.addEventListener("click", () => {
88
101
  state.agent = state.agent === agent.id ? null : agent.id;
89
- state.threadId = null;
102
+ setThreadFilter(null, null);
90
103
  refresh();
91
104
  });
92
105
  el.agents.appendChild(item);
@@ -96,7 +109,7 @@ function renderAgents(overview) {
96
109
  function renderMessages(messages) {
97
110
  el.filterLabel.textContent = [
98
111
  state.agent ? "agent : " + state.agent : null,
99
- state.threadId ? "fil : " + state.threadId : null,
112
+ state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
100
113
  ]
101
114
  .filter(Boolean)
102
115
  .join(" · ");
@@ -115,6 +128,8 @@ function renderMessages(messages) {
115
128
  subject.appendChild(badge);
116
129
  }
117
130
  item.appendChild(subject);
131
+ // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
132
+ if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
118
133
  item.appendChild(
119
134
  node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
120
135
  );
@@ -154,7 +169,7 @@ function renderDetail(message) {
154
169
  "\nid " +
155
170
  message.message_id +
156
171
  " · fil " +
157
- message.thread_id
172
+ (message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
158
173
  )
159
174
  );
160
175
 
@@ -192,11 +207,16 @@ async function showThread(threadId) {
192
207
  try {
193
208
  const data = await api("/admin/threads/" + encodeURIComponent(threadId));
194
209
  const container = node("div", "detail");
195
- container.appendChild(node("h3", null, "Fil " + threadId + " — " + data.messages.length + " message(s)"));
210
+ const title = (data.thread && data.thread.title) || null;
211
+ container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
212
+ if (title) container.appendChild(node("div", "meta", "fil " + threadId));
213
+ if (data.thread && data.thread.description) {
214
+ container.appendChild(node("div", "thread-description", data.thread.description));
215
+ }
196
216
 
197
217
  const filterButton = node("button", null, "Filtrer la liste sur ce fil");
198
218
  filterButton.addEventListener("click", () => {
199
- state.threadId = threadId;
219
+ setThreadFilter(threadId, title);
200
220
  refresh({ keepDetail: true });
201
221
  });
202
222
  container.appendChild(filterButton);
@@ -246,7 +266,7 @@ async function refresh(options) {
246
266
  el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
247
267
  el.clearFilters.addEventListener("click", () => {
248
268
  state.agent = null;
249
- state.threadId = null;
269
+ setThreadFilter(null, null);
250
270
  state.search = "";
251
271
  el.search.value = "";
252
272
  refresh({ keepDetail: true });
@@ -180,8 +180,15 @@ ul {
180
180
  outline: 1px solid var(--accent);
181
181
  }
182
182
 
183
- .agents .id {
183
+ .agents .name {
184
184
  font-weight: 600;
185
+ }
186
+
187
+ /* L'adresse reste lisible pour être recopiée, mais ne prend plus le pas sur le nom. */
188
+ .agents .id {
189
+ color: var(--muted);
190
+ font-size: 11px;
191
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
185
192
  word-break: break-all;
186
193
  }
187
194
 
@@ -216,6 +223,18 @@ ul {
216
223
  font-weight: 600;
217
224
  }
218
225
 
226
+ /* Nom du fil : au-dessus de l'expéditeur, en accent, pour rattacher le message d'un coup d'œil. */
227
+ .messages .thread-name {
228
+ color: var(--accent);
229
+ font-size: 12px;
230
+ margin-top: 2px;
231
+ }
232
+
233
+ .detail .thread-description {
234
+ color: var(--muted);
235
+ margin: 6px 0 10px;
236
+ }
237
+
219
238
  .messages .route {
220
239
  color: var(--muted);
221
240
  font-size: 12px;
@@ -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
+ }