@imenam/mcp-messenger 1.0.29 → 1.0.31

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.
@@ -35,6 +35,45 @@ export interface AgentDirectoryEntry extends Agent {
35
35
  /** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
36
36
  last_activity_at: string | null;
37
37
  }
38
+ /** Une des deux boîtes d'une liaison, et le moment où son agent en a pris connaissance. */
39
+ export interface LinkEnd {
40
+ mailbox: string;
41
+ /** null tant que l'agent de cette boîte n'a pas lu la liaison (`whoami`, `list_links`, `list_agents`). */
42
+ seen_at: string | null;
43
+ }
44
+ /**
45
+ * Liaison posée depuis la console entre deux boîtes : chacune connaît l'autre comme
46
+ * correspondante désignée, sans que les agents aient à échanger d'adresse.
47
+ */
48
+ export interface MailboxLink {
49
+ link_id: string;
50
+ /** Motif transmis aux deux agents. */
51
+ note: string | null;
52
+ created_at: string;
53
+ mailboxes: [LinkEnd, LinkEnd];
54
+ }
55
+ /** Liaison vue depuis l'une de ses deux boîtes : la fiche de la boîte d'en face. */
56
+ export interface LinkedMailbox {
57
+ link_id: string;
58
+ note: string | null;
59
+ created_at: string;
60
+ correspondent: Agent;
61
+ }
62
+ /** Pièce jointe telle qu'elle voyage avec un message : ses métadonnées, sans son contenu. */
63
+ export interface AttachmentInfo {
64
+ attachment_id: string;
65
+ filename: string;
66
+ mime_type: string;
67
+ /** Taille en octets. */
68
+ size: number;
69
+ }
70
+ /** Pièce jointe fournie à l'envoi. */
71
+ export interface AttachmentUpload {
72
+ filename: string;
73
+ /** Type MIME ; `application/octet-stream` s'il est absent. */
74
+ mime_type?: string | null;
75
+ data: Buffer;
76
+ }
38
77
  export interface SendMessageInput {
39
78
  from: string;
40
79
  /** Un échange lie exactement deux boîtes : un seul destinataire par message. */
@@ -47,6 +86,7 @@ export interface SendMessageInput {
47
86
  /** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
48
87
  thread_title?: string | null;
49
88
  thread_description?: string | null;
89
+ attachments?: AttachmentUpload[];
50
90
  }
51
91
  /**
52
92
  * Intitulé d'un fil de discussion.
@@ -98,6 +138,7 @@ export interface MessageSummary {
98
138
  starred: boolean;
99
139
  created_at: string;
100
140
  in_reply_to: string | null;
141
+ attachments: AttachmentInfo[];
101
142
  }
102
143
  export interface Message extends Omit<MessageSummary, "preview"> {
103
144
  body: string;
@@ -18,28 +18,31 @@ import { TOOLS, callTool } from "./tools.js";
18
18
  * recopier une adresse dans le corps d'un message (le serveur la pose déjà, la copie ment
19
19
  * dès qu'elle vieillit). Les dire ici, c'est les dire avant l'erreur.
20
20
  */
21
- const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
-
23
- Two words, two different things, used consistently by every tool here:
24
- - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
- - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
-
27
- Who you are, and who the others are — read this before writing to anyone:
28
- - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
- - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
- - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
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.
32
- - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
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
-
39
- Writing:
40
- - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
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.
42
- - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
21
+ const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
+
23
+ Two words, two different things, used consistently by every tool here:
24
+ - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
+ - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
+
27
+ Who you are, and who the others are — read this before writing to anyone:
28
+ - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
+ - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
+ - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
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.
32
+ - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
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
+
39
+ Linked mailboxes:
40
+ - The user can link this mailbox to others from the web console: those are the correspondents you are meant to work with. \`whoami\` and \`list_links\` list them, and the notification watcher announces each new link as it arrives.
41
+
42
+ Writing:
43
+ - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
44
+ - 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.
45
+ - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
43
46
  - The host stops that watcher after 30 minutes at most. Each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good.`;
44
47
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
45
48
  export function createMcpServer(client) {
@@ -1,5 +1,9 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { z } from "zod";
2
5
  import { writeHandoff } from "../cli/watch-handoff.js";
6
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "../core/attachments.js";
3
7
  import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
4
8
  import { rebind } from "./mailbox-registry.js";
5
9
  import { forgetCandidate, getResumeState } from "./resume-state.js";
@@ -7,6 +11,35 @@ import { forgetCandidate, getResumeState } from "./resume-state.js";
7
11
  const PACKAGE_NAME = "@imenam/mcp-messenger";
8
12
  /** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
9
13
  const MONITOR_TIMEOUT_MS = 1_800_000;
14
+ /** Répertoire où `save_attachment` écrit quand l'agent ne donne pas de destination. */
15
+ const DEFAULT_ATTACHMENT_DIR = path.join(os.tmpdir(), "mcp-messenger", "attachments");
16
+ /** Type MIME déduit de l'extension, pour les formats courants. */
17
+ const MIME_TYPES = {
18
+ ".txt": "text/plain",
19
+ ".md": "text/markdown",
20
+ ".csv": "text/csv",
21
+ ".json": "application/json",
22
+ ".xml": "application/xml",
23
+ ".yaml": "application/yaml",
24
+ ".yml": "application/yaml",
25
+ ".html": "text/html",
26
+ ".css": "text/css",
27
+ ".js": "text/javascript",
28
+ ".log": "text/plain",
29
+ ".pdf": "application/pdf",
30
+ ".png": "image/png",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif",
34
+ ".webp": "image/webp",
35
+ ".svg": "image/svg+xml",
36
+ ".zip": "application/zip",
37
+ ".gz": "application/gzip",
38
+ ".tar": "application/x-tar",
39
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
+ };
10
43
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
11
44
  export const TOOLS = [
12
45
  {
@@ -74,6 +107,11 @@ export const TOOLS = [
74
107
  additionalProperties: false,
75
108
  },
76
109
  },
110
+ {
111
+ name: "list_links",
112
+ description: "List the mailboxes the user has linked to this one from the web console, newest first: these are the correspondents you are meant to work with, with the reason the user gave. Write to them with send_message, using the address listed here. Links arrive while you work — the notification watcher announces each new one — and whoami lists them too.",
113
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
114
+ },
77
115
  {
78
116
  name: "list_agents",
79
117
  description: "List the mailboxes known to the server, grouped by agent name — one group per project, one line per session mailbox inside it, most recently active first. An agent name is shared by every session of the same project, so a group with several lines means several live sessions, each with its own mailbox address. Your own mailbox is never listed — you cannot write to yourself. Use it to find the mailbox address you want to write to; pass agent_name to expand a group whose sessions were not all listed.",
@@ -107,6 +145,23 @@ export const TOOLS = [
107
145
  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 (same effect as rename_thread).",
108
146
  },
109
147
  thread_description: { type: "string", description: "Optional longer description of the thread." },
148
+ attachments: {
149
+ type: "array",
150
+ maxItems: MAX_ATTACHMENTS,
151
+ description: `Files to attach, read from this machine's disk. At most ${MAX_ATTACHMENTS} files, ${formatBytes(MAX_ATTACHMENT_BYTES)} each and ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total. The recipient saves them with save_attachment.`,
152
+ items: {
153
+ type: "object",
154
+ properties: {
155
+ path: {
156
+ type: "string",
157
+ description: "Path of the file to attach. Prefer an absolute path; a relative one is resolved against this MCP server's working directory.",
158
+ },
159
+ name: { type: "string", description: "File name shown to the recipient. Defaults to the file's own name." },
160
+ },
161
+ required: ["path"],
162
+ additionalProperties: false,
163
+ },
164
+ },
110
165
  },
111
166
  required: ["to", "subject", "body"],
112
167
  additionalProperties: false,
@@ -152,6 +207,23 @@ export const TOOLS = [
152
207
  additionalProperties: false,
153
208
  },
154
209
  },
210
+ {
211
+ name: "save_attachment",
212
+ description: "Save a file attached to a message of this mailbox onto this machine's disk, and return the path it was written to. Attachment ids are listed under the message by read_message and get_thread. Read the saved file with your usual file tools.",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ attachment_id: { type: "string", description: "Attachment id (att_…) shown under the message." },
217
+ path: {
218
+ type: "string",
219
+ description: "Where to save it: an existing directory (the file keeps its name) or a file path. Defaults to a directory under the system temp folder.",
220
+ },
221
+ overwrite: { type: "boolean", description: "Replace an existing file at that path. Defaults to false." },
222
+ },
223
+ required: ["attachment_id"],
224
+ additionalProperties: false,
225
+ },
226
+ },
155
227
  {
156
228
  name: "mark_messages",
157
229
  description: "Mark messages as read or unread, and/or star or unstar them.",
@@ -244,6 +316,15 @@ const schemas = {
244
316
  reply_to: z.string().optional(),
245
317
  thread_name: z.string().trim().min(1).max(200).optional(),
246
318
  thread_description: z.string().trim().min(1).max(2000).optional(),
319
+ attachments: z
320
+ .array(z.object({ path: z.string().trim().min(1), name: z.string().trim().min(1).max(255).optional() }))
321
+ .max(MAX_ATTACHMENTS)
322
+ .optional(),
323
+ }),
324
+ save_attachment: z.object({
325
+ attachment_id: z.string().trim().min(1),
326
+ path: z.string().trim().min(1).optional(),
327
+ overwrite: z.boolean().optional(),
247
328
  }),
248
329
  list_messages: z.object({
249
330
  folder: z.string().optional(),
@@ -320,6 +401,7 @@ export async function callTool(client, name, rawArgs) {
320
401
  const health = await client.health().catch(() => null);
321
402
  const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
322
403
  const self = await client.getAgent(client.agentId).catch(() => null);
404
+ const links = await client.listLinks();
323
405
  const lines = [
324
406
  `Mailbox address: ${client.agentId}`,
325
407
  // Personne n'a besoin qu'on la lui envoie : l'annuaire la donne, et chaque message
@@ -335,6 +417,7 @@ export async function callTool(client, name, rawArgs) {
335
417
  self?.display_name
336
418
  ? `Mailbox name: ${self.display_name}`
337
419
  : "Mailbox name: none — call rename_mailbox so the user can tell this session apart from the others.",
420
+ ...linkLines(links),
338
421
  ];
339
422
  if (client.isAttachedElsewhere) {
340
423
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
@@ -415,16 +498,31 @@ export async function callTool(client, name, rawArgs) {
415
498
  return `No thread in this mailbox${scope} matching these filters.`;
416
499
  return [`${threads.length} thread(s)${scope}:`, "", ...threads.map(formatThread)].join("\n");
417
500
  }
501
+ case "list_links": {
502
+ const links = await client.listLinks();
503
+ if (links.length === 0) {
504
+ return "No mailbox is linked to this one yet. The user links mailboxes from the web console; the notification watcher announces each new link.";
505
+ }
506
+ return [
507
+ `${links.length} mailbox(es) linked to this one by the user, newest first:`,
508
+ "",
509
+ ...links.map(formatLink),
510
+ "",
511
+ "Write to them with send_message, using the address shown. Do not repeat the address in the message body.",
512
+ ].join("\n");
513
+ }
418
514
  case "list_agents": {
419
515
  const input = schemas.list_agents.parse(args);
420
516
  const agents = await client.listAgents();
421
517
  if (agents.length === 0)
422
518
  return "No other mailbox on this server yet.";
519
+ const linked = new Set((await client.listLinks()).map((link) => link.correspondent.id));
423
520
  const groups = groupByAgentName(agents);
424
- return input.agent_name ? formatAgentGroup(groups, input.agent_name) : formatDirectory(groups);
521
+ return input.agent_name ? formatAgentGroup(groups, input.agent_name, linked) : formatDirectory(groups, linked);
425
522
  }
426
523
  case "send_message": {
427
524
  const input = schemas.send_message.parse(args);
525
+ const attachments = await readAttachments(input.attachments ?? []);
428
526
  const result = await client.sendMessage({
429
527
  to: input.to,
430
528
  subject: input.subject,
@@ -434,12 +532,16 @@ export async function callTool(client, name, rawArgs) {
434
532
  // Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
435
533
  thread_title: input.thread_name ?? null,
436
534
  thread_description: input.thread_description ?? null,
535
+ attachments,
437
536
  });
438
537
  const lines = [
439
538
  `Message sent: ${result.message_id}`,
440
539
  `Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
441
540
  `Delivered to: ${result.delivered_to}`,
442
541
  ];
542
+ if (attachments.length > 0) {
543
+ lines.push(`Attachments: ${attachments.map((a) => a.filename).join(", ")}`);
544
+ }
443
545
  if (!result.thread_title && !input.reply_to) {
444
546
  lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
445
547
  }
@@ -472,6 +574,22 @@ export async function callTool(client, name, rawArgs) {
472
574
  .join("\n");
473
575
  return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
474
576
  }
577
+ case "save_attachment": {
578
+ const input = schemas.save_attachment.parse(args);
579
+ const attachment = await client.downloadAttachment(input.attachment_id);
580
+ const target = await attachmentTarget(input.path, input.attachment_id, attachment.filename);
581
+ await mkdir(path.dirname(target), { recursive: true });
582
+ try {
583
+ await writeFile(target, attachment.data, { flag: input.overwrite ? "w" : "wx" });
584
+ }
585
+ catch (error) {
586
+ if (error.code === "EEXIST") {
587
+ throw new Error(`a file already exists at ${target} — pass overwrite: true or choose another path`);
588
+ }
589
+ throw error;
590
+ }
591
+ return `Saved ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.data.length)}) to ${target}`;
592
+ }
475
593
  case "mark_messages": {
476
594
  const input = schemas.mark_messages.parse(args);
477
595
  if (input.read === undefined && input.starred === undefined) {
@@ -567,6 +685,42 @@ export async function callTool(client, name, rawArgs) {
567
685
  throw new Error(`Unknown tool: ${name}`);
568
686
  }
569
687
  }
688
+ /** Lit sur le disque local les fichiers à joindre, en refusant d'emblée ceux qui dépassent les limites. */
689
+ async function readAttachments(entries) {
690
+ let total = 0;
691
+ const attachments = [];
692
+ for (const entry of entries) {
693
+ const file = path.resolve(entry.path);
694
+ const info = await stat(file).catch(() => null);
695
+ if (!info?.isFile())
696
+ throw new Error(`attachment is not a readable file: ${file}`);
697
+ if (info.size > MAX_ATTACHMENT_BYTES) {
698
+ throw new Error(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${file} (${formatBytes(info.size)})`);
699
+ }
700
+ total += info.size;
701
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
702
+ throw new Error(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`);
703
+ }
704
+ const filename = entry.name ?? path.basename(file);
705
+ attachments.push({
706
+ filename,
707
+ mime_type: MIME_TYPES[path.extname(filename).toLowerCase()] ?? null,
708
+ content_base64: (await readFile(file)).toString("base64"),
709
+ });
710
+ }
711
+ return attachments;
712
+ }
713
+ /**
714
+ * Chemin d'écriture d'une pièce jointe : dans le répertoire donné sous son propre nom, au
715
+ * chemin donné s'il désigne un fichier, ou dans un répertoire temporaire propre à la pièce.
716
+ */
717
+ async function attachmentTarget(requested, attachmentId, filename) {
718
+ if (!requested)
719
+ return path.join(DEFAULT_ATTACHMENT_DIR, attachmentId, filename);
720
+ const resolved = path.resolve(requested);
721
+ const isDirectory = /[\\/]$/.test(requested) || (await stat(resolved).catch(() => null))?.isDirectory();
722
+ return isDirectory ? path.join(resolved, filename) : resolved;
723
+ }
570
724
  async function renameMailbox(client, name, description) {
571
725
  const agent = await client.updateProfile({ display_name: name, description });
572
726
  return [
@@ -611,9 +765,9 @@ function groupByAgentName(agents) {
611
765
  return [...groups.values()];
612
766
  }
613
767
  /** `boîte — nom · activité` : une ligne par session, l'adresse en tête car c'est elle qu'on copie. */
614
- function formatMailboxLine(agent) {
768
+ function formatMailboxLine(agent, linked) {
615
769
  const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
616
- const flags = agent.registered ? "" : " · never connected";
770
+ const flags = `${agent.registered ? "" : " · never connected"}${linked.has(agent.id) ? " · linked to you" : ""}`;
617
771
  return ` • ${agent.id} — ${agent.display_name ?? "no mailbox name"} · ${seen}${flags}`;
618
772
  }
619
773
  /**
@@ -630,7 +784,7 @@ const DIRECTORY_FOOTER = [
630
784
  "is never listed here. Always write to a mailbox address taken from this list, never to a name.",
631
785
  "To see what you have already exchanged with one of them, call list_threads with its address in with_mailbox.",
632
786
  ];
633
- function formatDirectory(groups) {
787
+ function formatDirectory(groups, linked) {
634
788
  const mailboxes = groups.reduce((total, group) => total + group.mailboxes.length, 0);
635
789
  const blocks = groups.map((group) => {
636
790
  const sessions = group.mailboxes.length;
@@ -640,7 +794,7 @@ function formatDirectory(groups) {
640
794
  // Un agent à sessions nombreuses ne doit pas noyer les autres : les plus récentes
641
795
  // suffisent à écrire, `agent_name` donne le reste à qui en a besoin.
642
796
  const shown = sessions > SESSIONS_SHOWN_PER_AGENT ? group.mailboxes.slice(0, 3) : group.mailboxes;
643
- lines.push(...shown.map(formatMailboxLine));
797
+ lines.push(...shown.map((agent) => formatMailboxLine(agent, linked)));
644
798
  if (shown.length < sessions) {
645
799
  lines.push(` … ${sessions - shown.length} more session(s) — list_agents with agent_name: "${group.name}" lists them all.`);
646
800
  }
@@ -656,7 +810,7 @@ function formatDirectory(groups) {
656
810
  ].join("\n");
657
811
  }
658
812
  /** Toutes les sessions d'un agent, décrites une par une : le second niveau de l'annuaire. */
659
- function formatAgentGroup(groups, requested) {
813
+ function formatAgentGroup(groups, requested, linked) {
660
814
  const wanted = requested.trim().toLowerCase();
661
815
  const group = groups.find((g) => g.name.toLowerCase() === wanted);
662
816
  if (!group) {
@@ -669,7 +823,7 @@ function formatAgentGroup(groups, requested) {
669
823
  `• ${agent.id}`,
670
824
  ` Mailbox: ${agent.display_name ?? "not named — this session never said what it works on"}`,
671
825
  agent.description ? ` ${agent.description}` : null,
672
- ` ${seen}${agent.registered ? "" : " · never connected"}`,
826
+ ` ${seen}${agent.registered ? "" : " · never connected"}${linked.has(agent.id) ? " · linked to you" : ""}`,
673
827
  ]
674
828
  .filter(Boolean)
675
829
  .join("\n");
@@ -729,6 +883,23 @@ function resumeLines(client) {
729
883
  }
730
884
  return lines;
731
885
  }
886
+ /** Ce que `whoami` dit des liaisons posées par l'utilisateur sur cette boîte. */
887
+ function linkLines(links) {
888
+ if (links.length === 0) {
889
+ return ["Linked mailboxes: none yet — the user links mailboxes from the web console."];
890
+ }
891
+ return [
892
+ "Linked mailboxes — correspondents the user designated for this session:",
893
+ ...links.map((link) => ` ${formatLink(link)}`),
894
+ " ↳ write to them with send_message; list_links details them.",
895
+ ];
896
+ }
897
+ /** `• adresse — Agent · Boîte · liée le … — motif` */
898
+ function formatLink(link) {
899
+ const names = formatAgentNames(link.correspondent) || "unnamed mailbox";
900
+ const note = link.note ? ` — ${link.note}` : "";
901
+ return `• ${link.correspondent.id} — ${names} · linked ${link.created_at}${note}`;
902
+ }
732
903
  /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
733
904
  function formatThreadRef(title, threadId) {
734
905
  return title ? `${title} (${threadId})` : threadId;
@@ -752,7 +923,7 @@ function formatSummary(message) {
752
923
  `${flags} ${message.subject}${priority}`,
753
924
  ` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
754
925
  ` ${message.preview}`,
755
- ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
926
+ ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}${message.attachments.length > 0 ? ` · ${message.attachments.length} attachment(s)` : ""}`,
756
927
  ].join("\n");
757
928
  }
758
929
  function formatMessage(message) {
@@ -767,5 +938,11 @@ function formatMessage(message) {
767
938
  ]
768
939
  .filter(Boolean)
769
940
  .join("\n");
770
- return `${header}\n\n${message.body}`;
941
+ const attachments = message.attachments.length > 0
942
+ ? `\n\nAttachments (save with save_attachment):\n${message.attachments.map(formatAttachment).join("\n")}`
943
+ : "";
944
+ return `${header}\n\n${message.body}${attachments}`;
945
+ }
946
+ function formatAttachment(attachment) {
947
+ return ` ${attachment.attachment_id} · ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.size)})`;
771
948
  }