From 830f621d842e97c40adf7d841166d77a04f2250c Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 24 Apr 2026 18:58:01 +0200 Subject: [PATCH 1/4] feat(adapters): add 4 homelab adapters (kimi, copilot, qwen, jinn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J3 du plan de pivot ai-peers → multiagents-homelab. - kimi-adapter.ts : Kimi K2.6 Moonshot (clone pattern Gemini, inbox+piggyback) - copilot-adapter.ts : GitHub Copilot CLI GPT-5.4 (warning explicite sur les shell commands longs entre tool calls, inbox read obligatoire) - qwen-adapter.ts : Alibaba Qwen CLI (clone pattern Gemini) - jinn-adapter.ts : bridge vers Jinn Gateway (13 employés virtuels, routing @employee, retry buffer + AbortController 10s) shared/types.ts + shared/constants.ts : étendus pour les 4 nouveaux AgentType (POLL_INTERVALS + AGENT_ID_PREFIXES). Import via bun validé pour les 4 classes. Warnings tsc strict (process, fetch, AbortController) non bloquants — endémiques au tsconfig du projet (pas de @types/node ni @types/bun). Co-Authored-By: Kimi K2.6 Co-Authored-By: DeepSeek R1 Co-Authored-By: Claude Opus 4.7 (1M context) --- adapters/copilot-adapter.ts | 289 ++++++++++++++++++++++++++++++++++++ adapters/jinn-adapter.ts | 136 +++++++++++++++++ adapters/kimi-adapter.ts | 286 +++++++++++++++++++++++++++++++++++ adapters/qwen-adapter.ts | 287 +++++++++++++++++++++++++++++++++++ shared/constants.ts | 8 + shared/types.ts | 2 +- 6 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 adapters/copilot-adapter.ts create mode 100644 adapters/jinn-adapter.ts create mode 100644 adapters/kimi-adapter.ts create mode 100644 adapters/qwen-adapter.ts diff --git a/adapters/copilot-adapter.ts b/adapters/copilot-adapter.ts new file mode 100644 index 0000000..0e88ff4 --- /dev/null +++ b/adapters/copilot-adapter.ts @@ -0,0 +1,289 @@ +#!/usr/bin/env bun +/** + * CopilotAdapter — MCP adapter for Copilot CLI instances. + * + * Nearly identical to CodexAdapter: no push capability. Messages are + * delivered via file-based inbox + piggybacked on tool responses. + */ + +import { BaseAdapter } from "./base-adapter.ts"; +import type { BufferedMessage } from "../shared/types.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export class CopilotAdapter extends BaseAdapter { + private messageBuffer: BufferedMessage[] = []; + private inboxPath: string | null = null; + private hasRunStartup = false; + + constructor() { + super("copilot"); + } + + getCapabilities(): Record { + return { + tools: {}, + }; + } + + getSystemPrompt(): string { + const inboxHint = this.inboxPath + ? `\nYour INBOX FILE: ${this.inboxPath}` + : "\nYour INBOX FILE: .multiagents/inbox/.md (created after registration)"; + + const base = `You are GitHub Copilot CLI (backed by GPT-5.4) on the multiagents network. + +⚠ IMPORTANT: Copilot executes long shell commands (build, tests, refactors) BETWEEN tool calls. +You MUST periodically read \`.multiagents/inbox/.md\` — do NOT rely only on MCP piggyback. +After every shell command longer than 10s, re-check the inbox. + +═══ COMMUNICATION IS YOUR #1 PRIORITY ═══ + +You have MCP tools from the "multiagents-peer" server. USE THEM — not shell commands, not HTTP calls, not CLI scripts. The MCP tools are: + set_summary, check_messages, send_message, check_team_status, get_plan, + update_plan, signal_done, submit_feedback, approve, list_peers, + acquire_file, release_file, view_file_locks, get_history + +BEFORE ANY OTHER WORK — call these MCP tools in this exact order: + 1. set_summary → describe your task in one line + 2. check_team_status → see your teammates and their roles + 3. get_plan → see the plan and your assigned items + 4. check_messages → read pending messages from teammates + +AFTER EVERY SHELL COMMAND OR FILE WRITE: + → Call check_messages (teammates may have sent critical updates) + → Call set_summary with what you just did + +AFTER EVERY 2-3 TOOL CALLS: + → Read your inbox file for real-time messages from teammates${inboxHint} + +WHEN YOU FINISH YOUR TASK: + → Call signal_done with what you built, tested, and results + → Then call check_messages every 10s waiting for review feedback + +WHEN A TEAMMATE MESSAGES YOU: + → Reply via send_message IMMEDIATELY — they are blocked waiting on you + +DO NOT: + ✗ Work for 1+ minute without calling check_messages + ✗ Finish without calling signal_done + ✗ Ignore teammate messages + ✗ Try to use CLI/HTTP to talk to the broker — use MCP tools only + ✗ Go silent — if you have nothing to do, call check_team_status and set_summary "Idle, ready to help" + +QUALITY: Production-grade code. Plan before coding. Verify before signaling done.`; + + if (this.roleContext) { + return `${base}\n\n--- ROLE CONTEXT ---\n${this.roleContext}`; + } + return base; + } + + /** + * Deliver a message by both buffering (for piggyback) and writing to + * the file-based inbox so Copilot sees it via native file reads. + */ + async deliverMessage(msg: BufferedMessage): Promise { + this.messageBuffer.push(msg); + await this.writeToInbox(msg); + } + + // --- File-based inbox --- + + private ensureInboxPath(): string | null { + if (this.inboxPath) return this.inboxPath; + + const slotId = this.mySlot?.id; + const name = this.mySlot?.display_name ?? (slotId ? `slot-${slotId}` : null); + if (!name) return null; + + const inboxDir = path.join(process.cwd(), ".multiagents", "inbox"); + try { + if (!fs.existsSync(inboxDir)) { + fs.mkdirSync(inboxDir, { recursive: true }); + } + } catch { + return null; + } + + const safeName = path.basename(name).replace(/[^a-zA-Z0-9_\-. ]/g, "_"); + this.inboxPath = path.join(inboxDir, `${safeName}.md`); + + try { + fs.writeFileSync(this.inboxPath, `# Inbox for ${name}\n\nMessages from teammates appear below. Newest at bottom.\n\n---\n\n`); + } catch { /* best effort */ } + + return this.inboxPath; + } + + private async writeToInbox(msg: BufferedMessage): Promise { + const inboxPath = this.ensureInboxPath(); + if (!inboxPath) return; + + try { + const timestamp = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); + const from = msg.from_display_name ?? msg.from_role ?? msg.from_id; + const entry = `**[${timestamp}] ${from}** (${msg.msg_type}):\n${msg.text}\n\n---\n\n`; + fs.appendFileSync(inboxPath, entry); + } catch { /* best effort */ } + } + + // --- Auto-startup: inject team context on first MCP tool call --- + + private async runAutoStartup(): Promise { + if (this.hasRunStartup) return ""; + this.hasRunStartup = true; + + const parts: string[] = [ + "╔══════════════════════════════════════════════════════════╗", + "║ AUTO-STARTUP: Team context injected on first tool call ║", + "╚══════════════════════════════════════════════════════════╝", + "", + ]; + + try { + const role = this.mySlot?.role ?? "team member"; + const name = this.mySlot?.display_name ?? "agent"; + await this.broker.setSummary(this.myId!, `${name} (${role}) — starting up, reading team context`); + parts.push(`✓ set_summary: Registered as ${name} (${role})`); + } catch (e) { + parts.push(`✗ set_summary failed: ${e instanceof Error ? e.message : String(e)}`); + } + + try { + const teamResult = await this.handleCheckTeamStatus(); + parts.push("", "── TEAM STATUS ──", teamResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_team_status failed"); } + + try { + const planResult = await this.handleGetPlan(); + parts.push("", "── YOUR PLAN ──", planResult.content[0]?.text ?? ""); + } catch { parts.push("✗ get_plan failed"); } + + try { + const msgResult = await this.handleCheckMessages(); + parts.push("", "── PENDING MESSAGES ──", msgResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_messages failed"); } + + parts.push( + "", + "── ACTION REQUIRED ──", + "Use multiagents-peer MCP tools to communicate:", + " → set_summary after every major action", + " → check_messages after every shell command", + " → send_message to reply to teammates", + " → signal_done when finished (with proof)", + " → update_plan to mark items in_progress/done", + "DO NOT go 1+ minute without calling check_messages.", + "════════════════════════════════════════════════════════════", + "", + ); + + return parts.join("\n"); + } + + protected wrapToolResult(result: string): string { + if (this.messageBuffer.length === 0) { + return result; + } + + const pending = this.messageBuffer + .map((m) => this.formatMessage(m)) + .join("\n"); + this.messageBuffer = []; + + return `[PENDING MESSAGES]\n${pending}\n[END PENDING MESSAGES]\n\n${result}`; + } + + protected override async handleListPeers(args: any) { + const r = await super.handleListPeers(args); + return this.wrapResult(r); + } + + protected override async handleSendMessage(args: any) { + const r = await super.handleSendMessage(args); + return this.wrapResult(r); + } + + protected override async handleSetSummary(args: any) { + const r = await super.handleSetSummary(args); + return this.wrapResult(r); + } + + protected override async handleCheckMessages() { + const r = await super.handleCheckMessages(); + return this.wrapResult(r); + } + + protected override async handleAssignRole(args: any) { + const r = await super.handleAssignRole(args); + return this.wrapResult(r); + } + + protected override async handleRenamePeer(args: any) { + const r = await super.handleRenamePeer(args); + return this.wrapResult(r); + } + + protected override async handleAcquireFile(args: any) { + const r = await super.handleAcquireFile(args); + return this.wrapResult(r); + } + + protected override async handleReleaseFile(args: any) { + const r = await super.handleReleaseFile(args); + return this.wrapResult(r); + } + + protected override async handleViewFileLocks() { + const r = await super.handleViewFileLocks(); + return this.wrapResult(r); + } + + protected override async handleGetHistory(args: any) { + const r = await super.handleGetHistory(args); + return this.wrapResult(r); + } + + // --- Lifecycle tool wrappers (critical for message delivery) --- + + protected override async handleSignalDone(args: any) { + const r = await super.handleSignalDone(args); + return this.wrapResult(r); + } + + protected override async handleSubmitFeedback(args: any) { + const r = await super.handleSubmitFeedback(args); + return this.wrapResult(r); + } + + protected override async handleApprove(args: any) { + const r = await super.handleApprove(args); + return this.wrapResult(r); + } + + protected override async handleCheckTeamStatus() { + const r = await super.handleCheckTeamStatus(); + return this.wrapResult(r); + } + + protected override async handleGetPlan() { + const r = await super.handleGetPlan(); + return this.wrapResult(r); + } + + protected override async handleUpdatePlan(args: any) { + const r = await super.handleUpdatePlan(args); + return this.wrapResult(r); + } + + private async wrapResult(result: { content: { type: string; text: string }[]; isError?: boolean }) { + const text = result.content[0]?.text ?? ""; + const startupContext = await this.runAutoStartup(); + const wrapped = this.wrapToolResult(text); + return { + ...result, + content: [{ type: "text" as const, text: startupContext + wrapped }], + }; + } +} diff --git a/adapters/jinn-adapter.ts b/adapters/jinn-adapter.ts new file mode 100644 index 0000000..6795e9a --- /dev/null +++ b/adapters/jinn-adapter.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env bun +/** + * JinnAdapter — MCP adapter acting as a bridge to the Jinn Gateway. + * + * Exposes the 13 Jinn AI employees as virtual peers on the multiagents + * network. Inbound messages are routed via HTTP POST to the local Jinn + * Gateway (default port 7777). This adapter does not run in driver mode; + * it relies on the broker's polling loop like a standard BaseAdapter. + */ + +import { BaseAdapter } from "./base-adapter.ts"; +import type { BufferedMessage } from "../shared/types.ts"; + +const JINN_GATEWAY_URL = process.env.JINN_GATEWAY_URL ?? "http://127.0.0.1:7777"; + +const EMPLOYEES: readonly string[] = [ + "gpt-coder", + "kimi-coder", + "deep-reasoner", + "deepseek-analyst", + "code-reviewer", + "remi-cohere", + "sam-cerebras", + "secops", + "sysadmin", + "tech-watch", + "diane-nim", + "kilocode-coder", + "alibaba-coder", +]; + +// TODO: confirmer l'endpoint exact de l'API Jinn Gateway (delegate vs sessions) + +export class JinnAdapter extends BaseAdapter { + private messageBuffer: BufferedMessage[] = []; + + constructor() { + super("jinn"); + } + + getCapabilities(): Record { + return { + tools: {}, + }; + } + + getSystemPrompt(): string { + const base = `You are a bridge adapter to the Jinn Gateway multi-AI team. +Your role is to proxy messages between the multiagents network and the +13 Jinn employees running on the local Gateway (${JINN_GATEWAY_URL}). + +VIRTUAL EMPLOYEES — mention one with @ in your message text: + • gpt-coder — GPT-5.4 (Copilot) + • kimi-coder — Kimi K2.6 (Moonshot) + • deep-reasoner — DeepSeek R1 (reasoning) + • deepseek-analyst — DeepSeek V3.2 (analysis) + • code-reviewer — Mistral Large (review) + • remi-cohere — Cohere Command A + • sam-cerebras — Cerebras Qwen3-235B + • secops — Hermes-4-70B (security) + • sysadmin — Qwen3-235B (OpenRouter) + • tech-watch — Gemini 2.5 Pro (R&D watch) + • diane-nim — Nemotron Ultra 253B (NIM) + • kilocode-coder — Claude Opus 4 (KiloCode) + • alibaba-coder — Qwen3-coder-next (DashScope) + +ROUTING RULES: + → Prefix your message with @ to choose a recipient. + → If you omit the mention, the gateway defaults to gpt-coder. + → Do NOT call Jinn HTTP endpoints yourself — use send_message. + +MCP TOOLS: set_summary, check_messages, send_message, check_team_status, +get_plan, update_plan, signal_done, submit_feedback, approve, list_peers, +acquire_file, release_file, view_file_locks, get_history`; + + if (this.roleContext) { + return `${base}\n\n--- ROLE CONTEXT ---\n${this.roleContext}`; + } + return base; + } + + async deliverMessage(msg: BufferedMessage): Promise { + // Retry previously failed deliveries first + const backlog = [...this.messageBuffer]; + this.messageBuffer = []; + for (const m of backlog) { + await this.dispatch(m); + } + await this.dispatch(msg); + } + + private async dispatch(msg: BufferedMessage): Promise { + const employee = this.resolveEmployee(msg); + const payload = { + employee, + task: msg.text, + from_id: msg.from_id, + from_role: msg.from_role, + msg_type: msg.msg_type, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + const res = await fetch(`${JINN_GATEWAY_URL}/delegate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error(`[JinnAdapter] Failed to route to ${employee}: ${error}`); + this.messageBuffer.push(msg); + } finally { + clearTimeout(timeout); + } + } + + private resolveEmployee(msg: BufferedMessage): string { + const mention = msg.text.match(/@([a-zA-Z0-9_-]+)/); + if (mention && EMPLOYEES.includes(mention[1])) { + return mention[1]; + } + if (msg.to_id && EMPLOYEES.includes(msg.to_id)) { + return msg.to_id; + } + return "gpt-coder"; + } +} diff --git a/adapters/kimi-adapter.ts b/adapters/kimi-adapter.ts new file mode 100644 index 0000000..378961f --- /dev/null +++ b/adapters/kimi-adapter.ts @@ -0,0 +1,286 @@ +#!/usr/bin/env bun +/** + * KimiAdapter — MCP adapter for Kimi CLI instances. + * + * Nearly identical to CodexAdapter: no push capability. Messages are + * delivered via file-based inbox + piggybacked on tool responses. + */ + +import { BaseAdapter } from "./base-adapter.ts"; +import type { BufferedMessage } from "../shared/types.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export class KimiAdapter extends BaseAdapter { + private messageBuffer: BufferedMessage[] = []; + private inboxPath: string | null = null; + private hasRunStartup = false; + + constructor() { + super("kimi"); + } + + getCapabilities(): Record { + return { + tools: {}, + }; + } + + getSystemPrompt(): string { + const inboxHint = this.inboxPath + ? `\nYour INBOX FILE: ${this.inboxPath}` + : "\nYour INBOX FILE: .multiagents/inbox/.md (created after registration)"; + + const base = `You are a Kimi K2.6 instance (Moonshot AI, reasoning always-on, 262K context window) on the multiagents network. +You are a team member on the multiagents network. You MUST use the multiagents-peer MCP tools to communicate. Your team CANNOT see your work unless you tell them. + +═══ COMMUNICATION IS YOUR #1 PRIORITY ═══ + +You have MCP tools from the "multiagents-peer" server. USE THEM — not shell commands, not HTTP calls, not CLI scripts. The MCP tools are: + set_summary, check_messages, send_message, check_team_status, get_plan, + update_plan, signal_done, submit_feedback, approve, list_peers, + acquire_file, release_file, view_file_locks, get_history + +BEFORE ANY OTHER WORK — call these MCP tools in this exact order: + 1. set_summary → describe your task in one line + 2. check_team_status → see your teammates and their roles + 3. get_plan → see the plan and your assigned items + 4. check_messages → read pending messages from teammates + +AFTER EVERY SHELL COMMAND OR FILE WRITE: + → Call check_messages (teammates may have sent critical updates) + → Call set_summary with what you just did + +AFTER EVERY 2-3 TOOL CALLS: + → Read your inbox file for real-time messages from teammates${inboxHint} + +WHEN YOU FINISH YOUR TASK: + → Call signal_done with what you built, tested, and results + → Then call check_messages every 10s waiting for review feedback + +WHEN A TEAMMATE MESSAGES YOU: + → Reply via send_message IMMEDIATELY — they are blocked waiting on you + +DO NOT: + ✗ Work for 1+ minute without calling check_messages + ✗ Finish without calling signal_done + ✗ Ignore teammate messages + ✗ Try to use CLI/HTTP to talk to the broker — use MCP tools only + ✗ Go silent — if you have nothing to do, call check_team_status and set_summary "Idle, ready to help" + +QUALITY: Production-grade code. Plan before coding. Verify before signaling done.`; + + if (this.roleContext) { + return `${base}\n\n--- ROLE CONTEXT ---\n${this.roleContext}`; + } + return base; + } + + /** + * Deliver a message by both buffering (for piggyback) and writing to + * the file-based inbox so Kimi sees it via native file reads. + */ + async deliverMessage(msg: BufferedMessage): Promise { + this.messageBuffer.push(msg); + await this.writeToInbox(msg); + } + + // --- File-based inbox --- + + private ensureInboxPath(): string | null { + if (this.inboxPath) return this.inboxPath; + + const slotId = this.mySlot?.id; + const name = this.mySlot?.display_name ?? (slotId ? `slot-${slotId}` : null); + if (!name) return null; + + const inboxDir = path.join(process.cwd(), ".multiagents", "inbox"); + try { + if (!fs.existsSync(inboxDir)) { + fs.mkdirSync(inboxDir, { recursive: true }); + } + } catch { + return null; + } + + const safeName = path.basename(name).replace(/[^a-zA-Z0-9_\-. ]/g, "_"); + this.inboxPath = path.join(inboxDir, `${safeName}.md`); + + try { + fs.writeFileSync(this.inboxPath, `# Inbox for ${name}\n\nMessages from teammates appear below. Newest at bottom.\n\n---\n\n`); + } catch { /* best effort */ } + + return this.inboxPath; + } + + private async writeToInbox(msg: BufferedMessage): Promise { + const inboxPath = this.ensureInboxPath(); + if (!inboxPath) return; + + try { + const timestamp = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); + const from = msg.from_display_name ?? msg.from_role ?? msg.from_id; + const entry = `**[${timestamp}] ${from}** (${msg.msg_type}):\n${msg.text}\n\n---\n\n`; + fs.appendFileSync(inboxPath, entry); + } catch { /* best effort */ } + } + + // --- Auto-startup: inject team context on first MCP tool call --- + + private async runAutoStartup(): Promise { + if (this.hasRunStartup) return ""; + this.hasRunStartup = true; + + const parts: string[] = [ + "╔══════════════════════════════════════════════════════════╗", + "║ AUTO-STARTUP: Team context injected on first tool call ║", + "╚══════════════════════════════════════════════════════════╝", + "", + ]; + + try { + const role = this.mySlot?.role ?? "team member"; + const name = this.mySlot?.display_name ?? "agent"; + await this.broker.setSummary(this.myId!, `${name} (${role}) — starting up, reading team context`); + parts.push(`✓ set_summary: Registered as ${name} (${role})`); + } catch (e) { + parts.push(`✗ set_summary failed: ${e instanceof Error ? e.message : String(e)}`); + } + + try { + const teamResult = await this.handleCheckTeamStatus(); + parts.push("", "── TEAM STATUS ──", teamResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_team_status failed"); } + + try { + const planResult = await this.handleGetPlan(); + parts.push("", "── YOUR PLAN ──", planResult.content[0]?.text ?? ""); + } catch { parts.push("✗ get_plan failed"); } + + try { + const msgResult = await this.handleCheckMessages(); + parts.push("", "── PENDING MESSAGES ──", msgResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_messages failed"); } + + parts.push( + "", + "── ACTION REQUIRED ──", + "Use multiagents-peer MCP tools to communicate:", + " → set_summary after every major action", + " → check_messages after every shell command", + " → send_message to reply to teammates", + " → signal_done when finished (with proof)", + " → update_plan to mark items in_progress/done", + "DO NOT go 1+ minute without calling check_messages.", + "════════════════════════════════════════════════════════════", + "", + ); + + return parts.join("\n"); + } + + protected wrapToolResult(result: string): string { + if (this.messageBuffer.length === 0) { + return result; + } + + const pending = this.messageBuffer + .map((m) => this.formatMessage(m)) + .join("\n"); + this.messageBuffer = []; + + return `[PENDING MESSAGES]\n${pending}\n[END PENDING MESSAGES]\n\n${result}`; + } + + protected override async handleListPeers(args: any) { + const r = await super.handleListPeers(args); + return this.wrapResult(r); + } + + protected override async handleSendMessage(args: any) { + const r = await super.handleSendMessage(args); + return this.wrapResult(r); + } + + protected override async handleSetSummary(args: any) { + const r = await super.handleSetSummary(args); + return this.wrapResult(r); + } + + protected override async handleCheckMessages() { + const r = await super.handleCheckMessages(); + return this.wrapResult(r); + } + + protected override async handleAssignRole(args: any) { + const r = await super.handleAssignRole(args); + return this.wrapResult(r); + } + + protected override async handleRenamePeer(args: any) { + const r = await super.handleRenamePeer(args); + return this.wrapResult(r); + } + + protected override async handleAcquireFile(args: any) { + const r = await super.handleAcquireFile(args); + return this.wrapResult(r); + } + + protected override async handleReleaseFile(args: any) { + const r = await super.handleReleaseFile(args); + return this.wrapResult(r); + } + + protected override async handleViewFileLocks() { + const r = await super.handleViewFileLocks(); + return this.wrapResult(r); + } + + protected override async handleGetHistory(args: any) { + const r = await super.handleGetHistory(args); + return this.wrapResult(r); + } + + // --- Lifecycle tool wrappers (critical for message delivery) --- + + protected override async handleSignalDone(args: any) { + const r = await super.handleSignalDone(args); + return this.wrapResult(r); + } + + protected override async handleSubmitFeedback(args: any) { + const r = await super.handleSubmitFeedback(args); + return this.wrapResult(r); + } + + protected override async handleApprove(args: any) { + const r = await super.handleApprove(args); + return this.wrapResult(r); + } + + protected override async handleCheckTeamStatus() { + const r = await super.handleCheckTeamStatus(); + return this.wrapResult(r); + } + + protected override async handleGetPlan() { + const r = await super.handleGetPlan(); + return this.wrapResult(r); + } + + protected override async handleUpdatePlan(args: any) { + const r = await super.handleUpdatePlan(args); + return this.wrapResult(r); + } + + private async wrapResult(result: { content: { type: string; text: string }[]; isError?: boolean }) { + const text = result.content[0]?.text ?? ""; + const startupContext = await this.runAutoStartup(); + const wrapped = this.wrapToolResult(text); + return { + ...result, + content: [{ type: "text" as const, text: startupContext + wrapped }], + }; + } +} diff --git a/adapters/qwen-adapter.ts b/adapters/qwen-adapter.ts new file mode 100644 index 0000000..fbe1a8b --- /dev/null +++ b/adapters/qwen-adapter.ts @@ -0,0 +1,287 @@ +#!/usr/bin/env bun +/** + * QwenAdapter — MCP adapter for Qwen CLI instances. + * + * Nearly identical to CodexAdapter: no push capability. Messages are + * delivered via file-based inbox + piggybacked on tool responses. + */ + +import { BaseAdapter } from "./base-adapter.ts"; +import type { BufferedMessage } from "../shared/types.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export class QwenAdapter extends BaseAdapter { + private messageBuffer: BufferedMessage[] = []; + private inboxPath: string | null = null; + private hasRunStartup = false; + + constructor() { + super("qwen"); + } + + getCapabilities(): Record { + return { + tools: {}, + }; + } + + getSystemPrompt(): string { + const inboxHint = this.inboxPath + ? `\nYour INBOX FILE: ${this.inboxPath}` + : "\nYour INBOX FILE: .multiagents/inbox/.md (created after registration)"; + + const base = `You are an Alibaba Qwen CLI instance (Qwen3 / Qwen2.5-coder) on the multiagents network. + +You are a team member on the multiagents network. You MUST use the multiagents-peer MCP tools to communicate. Your team CANNOT see your work unless you tell them. + +═══ COMMUNICATION IS YOUR #1 PRIORITY ═══ + +You have MCP tools from the "multiagents-peer" server. USE THEM — not shell commands, not HTTP calls, not CLI scripts. The MCP tools are: + set_summary, check_messages, send_message, check_team_status, get_plan, + update_plan, signal_done, submit_feedback, approve, list_peers, + acquire_file, release_file, view_file_locks, get_history + +BEFORE ANY OTHER WORK — call these MCP tools in this exact order: + 1. set_summary → describe your task in one line + 2. check_team_status → see your teammates and their roles + 3. get_plan → see the plan and your assigned items + 4. check_messages → read pending messages from teammates + +AFTER EVERY SHELL COMMAND OR FILE WRITE: + → Call check_messages (teammates may have sent critical updates) + → Call set_summary with what you just did + +AFTER EVERY 2-3 TOOL CALLS: + → Read your inbox file for real-time messages from teammates${inboxHint} + +WHEN YOU FINISH YOUR TASK: + → Call signal_done with what you built, tested, and results + → Then call check_messages every 10s waiting for review feedback + +WHEN A TEAMMATE MESSAGES YOU: + → Reply via send_message IMMEDIATELY — they are blocked waiting on you + +DO NOT: + ✗ Work for 1+ minute without calling check_messages + ✗ Finish without calling signal_done + ✗ Ignore teammate messages + ✗ Try to use CLI/HTTP to talk to the broker — use MCP tools only + ✗ Go silent — if you have nothing to do, call check_team_status and set_summary "Idle, ready to help" + +QUALITY: Production-grade code. Plan before coding. Verify before signaling done.`; + + if (this.roleContext) { + return `${base}\n\n--- ROLE CONTEXT ---\n${this.roleContext}`; + } + return base; + } + + /** + * Deliver a message by both buffering (for piggyback) and writing to + * the file-based inbox so Qwen sees it via native file reads. + */ + async deliverMessage(msg: BufferedMessage): Promise { + this.messageBuffer.push(msg); + await this.writeToInbox(msg); + } + + // --- File-based inbox --- + + private ensureInboxPath(): string | null { + if (this.inboxPath) return this.inboxPath; + + const slotId = this.mySlot?.id; + const name = this.mySlot?.display_name ?? (slotId ? `slot-${slotId}` : null); + if (!name) return null; + + const inboxDir = path.join(process.cwd(), ".multiagents", "inbox"); + try { + if (!fs.existsSync(inboxDir)) { + fs.mkdirSync(inboxDir, { recursive: true }); + } + } catch { + return null; + } + + const safeName = path.basename(name).replace(/[^a-zA-Z0-9_\-. ]/g, "_"); + this.inboxPath = path.join(inboxDir, `${safeName}.md`); + + try { + fs.writeFileSync(this.inboxPath, `# Inbox for ${name}\n\nMessages from teammates appear below. Newest at bottom.\n\n---\n\n`); + } catch { /* best effort */ } + + return this.inboxPath; + } + + private async writeToInbox(msg: BufferedMessage): Promise { + const inboxPath = this.ensureInboxPath(); + if (!inboxPath) return; + + try { + const timestamp = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); + const from = msg.from_display_name ?? msg.from_role ?? msg.from_id; + const entry = `**[${timestamp}] ${from}** (${msg.msg_type}):\n${msg.text}\n\n---\n\n`; + fs.appendFileSync(inboxPath, entry); + } catch { /* best effort */ } + } + + // --- Auto-startup: inject team context on first MCP tool call --- + + private async runAutoStartup(): Promise { + if (this.hasRunStartup) return ""; + this.hasRunStartup = true; + + const parts: string[] = [ + "╔══════════════════════════════════════════════════════════╗", + "║ AUTO-STARTUP: Team context injected on first tool call ║", + "╚══════════════════════════════════════════════════════════╝", + "", + ]; + + try { + const role = this.mySlot?.role ?? "team member"; + const name = this.mySlot?.display_name ?? "agent"; + await this.broker.setSummary(this.myId!, `${name} (${role}) — starting up, reading team context`); + parts.push(`✓ set_summary: Registered as ${name} (${role})`); + } catch (e) { + parts.push(`✗ set_summary failed: ${e instanceof Error ? e.message : String(e)}`); + } + + try { + const teamResult = await this.handleCheckTeamStatus(); + parts.push("", "── TEAM STATUS ──", teamResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_team_status failed"); } + + try { + const planResult = await this.handleGetPlan(); + parts.push("", "── YOUR PLAN ──", planResult.content[0]?.text ?? ""); + } catch { parts.push("✗ get_plan failed"); } + + try { + const msgResult = await this.handleCheckMessages(); + parts.push("", "── PENDING MESSAGES ──", msgResult.content[0]?.text ?? ""); + } catch { parts.push("✗ check_messages failed"); } + + parts.push( + "", + "── ACTION REQUIRED ──", + "Use multiagents-peer MCP tools to communicate:", + " → set_summary after every major action", + " → check_messages after every shell command", + " → send_message to reply to teammates", + " → signal_done when finished (with proof)", + " → update_plan to mark items in_progress/done", + "DO NOT go 1+ minute without calling check_messages.", + "════════════════════════════════════════════════════════════", + "", + ); + + return parts.join("\n"); + } + + protected wrapToolResult(result: string): string { + if (this.messageBuffer.length === 0) { + return result; + } + + const pending = this.messageBuffer + .map((m) => this.formatMessage(m)) + .join("\n"); + this.messageBuffer = []; + + return `[PENDING MESSAGES]\n${pending}\n[END PENDING MESSAGES]\n\n${result}`; + } + + protected override async handleListPeers(args: any) { + const r = await super.handleListPeers(args); + return this.wrapResult(r); + } + + protected override async handleSendMessage(args: any) { + const r = await super.handleSendMessage(args); + return this.wrapResult(r); + } + + protected override async handleSetSummary(args: any) { + const r = await super.handleSetSummary(args); + return this.wrapResult(r); + } + + protected override async handleCheckMessages() { + const r = await super.handleCheckMessages(); + return this.wrapResult(r); + } + + protected override async handleAssignRole(args: any) { + const r = await super.handleAssignRole(args); + return this.wrapResult(r); + } + + protected override async handleRenamePeer(args: any) { + const r = await super.handleRenamePeer(args); + return this.wrapResult(r); + } + + protected override async handleAcquireFile(args: any) { + const r = await super.handleAcquireFile(args); + return this.wrapResult(r); + } + + protected override async handleReleaseFile(args: any) { + const r = await super.handleReleaseFile(args); + return this.wrapResult(r); + } + + protected override async handleViewFileLocks() { + const r = await super.handleViewFileLocks(); + return this.wrapResult(r); + } + + protected override async handleGetHistory(args: any) { + const r = await super.handleGetHistory(args); + return this.wrapResult(r); + } + + // --- Lifecycle tool wrappers (critical for message delivery) --- + + protected override async handleSignalDone(args: any) { + const r = await super.handleSignalDone(args); + return this.wrapResult(r); + } + + protected override async handleSubmitFeedback(args: any) { + const r = await super.handleSubmitFeedback(args); + return this.wrapResult(r); + } + + protected override async handleApprove(args: any) { + const r = await super.handleApprove(args); + return this.wrapResult(r); + } + + protected override async handleCheckTeamStatus() { + const r = await super.handleCheckTeamStatus(); + return this.wrapResult(r); + } + + protected override async handleGetPlan() { + const r = await super.handleGetPlan(); + return this.wrapResult(r); + } + + protected override async handleUpdatePlan(args: any) { + const r = await super.handleUpdatePlan(args); + return this.wrapResult(r); + } + + private async wrapResult(result: { content: { type: string; text: string }[]; isError?: boolean }) { + const text = result.content[0]?.text ?? ""; + const startupContext = await this.runAutoStartup(); + const wrapped = this.wrapToolResult(text); + return { + ...result, + content: [{ type: "text" as const, text: startupContext + wrapped }], + }; + } +} diff --git a/shared/constants.ts b/shared/constants.ts index 9679075..ea0c0c6 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -16,6 +16,10 @@ export const POLL_INTERVALS: Record = { claude: 1000, // Claude uses channel push — polling is fallback only codex: 300, // Aggressive: piggyback delivery depends on fast buffering gemini: 300, // Same as Codex — no push capability + kimi: 500, // Kimi K2.6 — push capability to be tested, fallback polling + copilot: 300, // Copilot CLI — no push, aggressive polling for piggyback + qwen: 300, // Qwen CLI — same as Gemini, no push + jinn: 1000, // Jinn bridge — gateway API bridge, moderate polling custom: 500, // Reasonable default for unknown agents }; @@ -46,6 +50,10 @@ export const AGENT_ID_PREFIXES: Record = { claude: "cl", codex: "cx", gemini: "gm", + kimi: "km", + copilot: "cp", + qwen: "qw", + jinn: "jn", custom: "cu", }; diff --git a/shared/types.ts b/shared/types.ts index c1d8736..bfbed8d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -10,7 +10,7 @@ export type PeerId = string; /** Supported agent CLI types */ -export type AgentType = "claude" | "codex" | "gemini" | "custom"; +export type AgentType = "claude" | "codex" | "gemini" | "kimi" | "copilot" | "qwen" | "jinn" | "custom"; /** Message types for routing and formatting */ export type MessageType = From 28873c3014e95eab16d41817d1ec171a09cd8e4e Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 24 Apr 2026 19:02:53 +0200 Subject: [PATCH 2/4] feat(server): wire kimi/copilot/qwen/jinn adapters in dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 nouveaux cases dans le switch de server.ts. Chaque case fait un dynamic import de l'adapter correspondant puis appelle .start(). Smoke tests (timeout 3s) : les 4 types démarrent le handshake MCP + auto-lancent le broker sans erreur — KimiAdapter, CopilotAdapter, QwenAdapter, JinnAdapter opérationnels. Co-Authored-By: Kimi K2.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- server.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/server.ts b/server.ts index 9dbffd3..5a36565 100755 --- a/server.ts +++ b/server.ts @@ -8,6 +8,10 @@ * bun server.ts # defaults to claude * bun server.ts --agent-type codex * bun server.ts --agent-type gemini + * bun server.ts --agent-type kimi + * bun server.ts --agent-type copilot + * bun server.ts --agent-type qwen + * bun server.ts --agent-type jinn */ import type { AgentType } from "./shared/types.ts"; @@ -35,6 +39,26 @@ async function main() { await new GeminiAdapter().start(); break; } + case "kimi": { + const { KimiAdapter } = await import("./adapters/kimi-adapter.ts"); + await new KimiAdapter().start(); + break; + } + case "copilot": { + const { CopilotAdapter } = await import("./adapters/copilot-adapter.ts"); + await new CopilotAdapter().start(); + break; + } + case "qwen": { + const { QwenAdapter } = await import("./adapters/qwen-adapter.ts"); + await new QwenAdapter().start(); + break; + } + case "jinn": { + const { JinnAdapter } = await import("./adapters/jinn-adapter.ts"); + await new JinnAdapter().start(); + break; + } default: { // For 'custom' or unknown types, fall back to Claude adapter behavior const { ClaudeAdapter } = await import("./adapters/claude-adapter.ts"); @@ -45,8 +69,6 @@ async function main() { } main().catch((e) => { - console.error( - `[multiagents] Fatal: ${e instanceof Error ? e.message : String(e)}`, - ); + console.error(`[multiagents] Fatal: ${e instanceof Error ? e.message : String(e)}`); process.exit(1); }); From 9a2df39ed9a9212f281f5c23fd36f68c34d9a5c7 Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 24 Apr 2026 20:47:17 +0200 Subject: [PATCH 3/4] test: add integration tests for 4 homelab adapters Integration test that spawns a real broker on port 7901 and a real adapter subprocess for each of the 4 new agent types, performs the MCP initialize handshake, then asserts the registration line in stderr has the correct agent ID prefix (km-/cp-/qw-/jn-). Result: 4/4 pass in 11.33s, 8 expect() calls. Co-Authored-By: Kimi K2.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/homelab-adapters.test.ts | 88 ++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/homelab-adapters.test.ts diff --git a/tests/homelab-adapters.test.ts b/tests/homelab-adapters.test.ts new file mode 100644 index 0000000..2516340 --- /dev/null +++ b/tests/homelab-adapters.test.ts @@ -0,0 +1,88 @@ +import { test, expect, describe, beforeAll, afterAll } from "bun:test"; +import { spawn, type Subprocess } from "bun"; +import * as path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const TEST_PORT = "7901"; // éviter conflit avec brokers dev/prod + +let broker: Subprocess; + +beforeAll(async () => { + broker = spawn({ + cmd: ["bun", `${ROOT}/broker.ts`], + env: { ...process.env, MULTIAGENTS_PORT: TEST_PORT }, + stdout: "pipe", + stderr: "pipe", + }); + // attendre que le broker bind le port + for (let i = 0; i < 30; i++) { + try { + const r = await fetch(`http://127.0.0.1:${TEST_PORT}/health`); + if (r.ok) return; + } catch {} + await Bun.sleep(100); + } + throw new Error("broker did not start within 3s"); +}); + +afterAll(() => { + broker?.kill(); +}); + +describe("homelab adapters register with correct prefix", () => { + const cases: Array<{ type: string; prefix: string; className: string }> = [ + { type: "kimi", prefix: "km-", className: "KimiAdapter" }, + { type: "copilot", prefix: "cp-", className: "CopilotAdapter" }, + { type: "qwen", prefix: "qw-", className: "QwenAdapter" }, + { type: "jinn", prefix: "jn-", className: "JinnAdapter" }, + ]; + + for (const { type, prefix, className } of cases) { + test(`${type} → ${className} registers with ${prefix} prefix`, async () => { + const adapter = spawn({ + cmd: ["bun", `${ROOT}/server.ts`, "--agent-type", type], + env: { ...process.env, MULTIAGENTS_PORT: TEST_PORT }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send MCP initialize to unblock the handshake + adapter.stdin.write(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-probe", version: "1" }, + }, + }) + "\n"); + adapter.stdin.flush(); + + // Give the adapter time to register with the broker + await Bun.sleep(2500); + + // Read stderr to capture the registration line + const reader = adapter.stderr.getReader(); + const chunks: string[] = []; + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const race = await Promise.race([ + reader.read(), + new Promise((res) => setTimeout(() => res({ done: true, value: null }), 100)), + ]); + if (race.done || !race.value) break; + chunks.push(new TextDecoder().decode(race.value)); + } + const stderrText = chunks.join(""); + + adapter.kill(); + await Bun.sleep(200); + + const match = stderrText.match(/Registered as peer (\S+)/); + expect(match).not.toBeNull(); + expect(match![1]).toMatch(new RegExp(`^${prefix}`)); + }, 10000); // timeout 10s + } +}); From 5e07d065025667a4df4906ae80a091e3d8646544 Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 24 Apr 2026 20:53:13 +0200 Subject: [PATCH 4/4] fix(jinn-adapter): tighten resolveEmployee types for noUncheckedIndexedAccess The previous resolveEmployee() passed a potentially-undefined `mention[1]` into `EMPLOYEES.includes()` and returned it directly. Under TypeScript's `noUncheckedIndexedAccess` this is a type error. Destructure into a local `mentioned: string | undefined`, guard it, and widen `EMPLOYEES` to `readonly string[]` via cast so the narrowed string fits `Array.includes`. Same pattern applied to the `msg.to_id` branch. Co-Authored-By: Kimi K2.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- adapters/jinn-adapter.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/adapters/jinn-adapter.ts b/adapters/jinn-adapter.ts index 6795e9a..345c832 100644 --- a/adapters/jinn-adapter.ts +++ b/adapters/jinn-adapter.ts @@ -125,10 +125,11 @@ acquire_file, release_file, view_file_locks, get_history`; private resolveEmployee(msg: BufferedMessage): string { const mention = msg.text.match(/@([a-zA-Z0-9_-]+)/); - if (mention && EMPLOYEES.includes(mention[1])) { - return mention[1]; + const mentioned = mention?.[1]; + if (mentioned && (EMPLOYEES as readonly string[]).includes(mentioned)) { + return mentioned; } - if (msg.to_id && EMPLOYEES.includes(msg.to_id)) { + if (msg.to_id && (EMPLOYEES as readonly string[]).includes(msg.to_id)) { return msg.to_id; } return "gpt-coder";