From 6de05718c8aaa167971677336cc0c8fb669f913d Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 24 Oct 2026 13:56:33 +0000 Subject: [PATCH] mcp: open session agents and the hub --- packages/simplex-chat-mcp/src/agent.ts | 72 ++++++++++++++ packages/simplex-chat-mcp/src/hub.ts | 96 +++++++++++++++++++ .../simplex-chat-mcp/test/unit/agent.test.ts | 15 +++ 3 files changed, 183 insertions(+) create mode 100644 packages/simplex-chat-mcp/src/agent.ts create mode 100644 packages/simplex-chat-mcp/src/hub.ts create mode 100644 packages/simplex-chat-mcp/test/unit/agent.test.ts diff --git a/packages/simplex-chat-mcp/src/agent.ts b/packages/simplex-chat-mcp/src/agent.ts new file mode 100644 index 0000000000..c813ff8cef --- /dev/null +++ b/packages/simplex-chat-mcp/src/agent.ts @@ -0,0 +1,72 @@ +import {basename} from "path" +import {T} from "@simplex-chat/types" +import {api} from "simplex-chat" +import {Actions} from "./actions" +import {Core, openCore, updatedUser} from "./core" +import {ChatItemsWatch, validName} from "./messages" +import {AgentTools} from "./tools" + +const profilePrefix = "SimpleX MCP - " +const unnamedSession = "Claude Code" + +export interface SessionAgent { + readonly number: number + readonly chat: api.ChatApi + readonly tools: AgentTools + readonly displayName: string + rename(name: string): Promise + close(): Promise +} + +export interface SessionAgentOptions { + folder: string + number: number + name: string + actions: Actions + standingApprover: (chatInfo: T.ChatInfo) => string | undefined +} + +export function sessionName(title: string | undefined, project: string): string { + const named = title?.trim() + if (named) return named + return basename(project) || unnamedSession +} + +export function profileName(name: string): string { + return validName(`${profilePrefix}${name}`) +} + +export async function openSessionAgent({folder, number, name, actions, standingApprover}: SessionAgentOptions): Promise { + const core: Core = await openCore(folder, profileName(name)) + const {chat, user} = core + const watch = new ChatItemsWatch() + const tools = new AgentTools({chat, user, actions, watch, session: number, standingApprover}) + await core.events(async event => { + switch (event.type) { + case "newChatItems": + return watch.deliver(event.chatItems) + case "chatItemUpdated": + return watch.update(event.chatItem) + case "chatItemsDeleted": + return watch.remove(event.chatItemDeletions.map(({deletedChatItem}) => deletedChatItem)) + case "contactConnected": + return tools.onContactConnected(event) + case "userJoinedGroup": + return tools.onUserJoinedGroup(event) + } + }) + const agent: SessionAgent = { + number, + chat, + tools, + get displayName() { + return user.profile.displayName + }, + async rename(newName) { + const renamed = await updatedUser(chat, user, profileName(newName)) + Object.assign(user.profile, renamed.profile) + }, + close: () => chat.close(), + } + return agent +} diff --git a/packages/simplex-chat-mcp/src/hub.ts b/packages/simplex-chat-mcp/src/hub.ts new file mode 100644 index 0000000000..8cfdbc249c --- /dev/null +++ b/packages/simplex-chat-mcp/src/hub.ts @@ -0,0 +1,96 @@ +import {ChatEvent, T} from "@simplex-chat/types" +import {api} from "simplex-chat" +import {Action, Actions} from "./actions" +import {Config} from "./config" +import {Control, Sessions, controlDataOf, createControlChat, findControlGroup, isControlEvent} from "./control" +import {deliveries, openCore, updatedUser} from "./core" +import {hubFolder} from "./files" +import {logError} from "./log" +import {OwnerState} from "./protocol" + +export type Perform = (action: Action, signal: AbortSignal) => Promise + +export class Hub { + private stopped = false + private controlEvents: Promise = Promise.resolve() + + private constructor( + private readonly chat: api.ChatApi, + private user: T.User, + readonly actions: Actions, + readonly control: Control, + ) {} + + static async open(root: string, config: Config, sessions: Sessions, perform: Perform): Promise { + const core = await openCore(hubFolder(root), config.mainGroup) + try { + const {chat, user} = core + const group = (await findControlGroup(chat, user.userId)) ?? (await createControlChat(chat, user.userId, config.mainGroup, 1)) + const actions: Actions = new Actions(config.approve, config.expireHours, controlDataOf(group.customData)?.nextAction ?? 1, { + refusal: action => control.refusal(action), + saveCounter: next => control.saveCounter(next), + postCard: action => control.postCard(action), + outcome: action => control.outcome(action), + perform, + }) + const control: Control = new Control(chat, group, {displayName: config.mainGroup}, actions, sessions) + await control.load() + const hub = new Hub(chat, user, actions, control) + await core.events(event => hub.onEvent(event)) + await control.start() + return hub + } catch (error) { + await core.chat.close().catch(() => undefined) + throw error + } + } + + get mainGroup(): string { + return this.user.profile.displayName + } + + owner(): OwnerState { + const {link} = this.control + return {owners: this.control.owners(), ...(link === undefined ? {} : {link})} + } + + standingApprover(chatInfo: T.ChatInfo): string | undefined { + return this.control.standingApprover(chatInfo) + } + + async configure(config: Config): Promise { + this.actions.mode = config.approve + this.actions.expireHours = config.expireHours + if (config.mainGroup === this.mainGroup) return + this.user = await updatedUser(this.chat, this.user, config.mainGroup) + await this.control.rename(config.mainGroup) + } + + resetOwners(): Promise { + const reset = this.controlEvents.then(() => this.control.reset(this.user.userId)) + this.controlEvents = reset.then(() => undefined, error => logError("owner reset", error)) + return reset + } + + async stop(): Promise { + this.stopped = true + const cancelled = await this.actions.stop() + await deliveries(this.chat, this.control.groupId) + await this.chat.close() + return cancelled + } + + private async onEvent(event: ChatEvent): Promise { + if (this.stopped) return + const {control} = this + if (event.type === "newChatItems") { + for (const ci of event.chatItems) { + if (ci.chatInfo.type === "group" && ci.chatInfo.groupInfo.groupId === control.groupId && ci.chatInfo.groupChatScope === undefined) control.onMessage(ci) + } + return + } + if (isControlEvent(event, control.groupId)) { + this.controlEvents = this.controlEvents.then(() => control.onEvent(event)).catch(error => logError(`${event.type} in the Main group`, error)) + } + } +} diff --git a/packages/simplex-chat-mcp/test/unit/agent.test.ts b/packages/simplex-chat-mcp/test/unit/agent.test.ts new file mode 100644 index 0000000000..690830ad3e --- /dev/null +++ b/packages/simplex-chat-mcp/test/unit/agent.test.ts @@ -0,0 +1,15 @@ +import {profileName, sessionName} from "../../src/agent" + +describe("session profile names", () => { + it("use the title, else the project folder, else Claude Code", () => { + expect(sessionName(" auth refactor ", "/p/app")).toBe("auth refactor") + expect(sessionName(undefined, "/p/simplex-chat-6")).toBe("simplex-chat-6") + expect(sessionName("", "/")).toBe("Claude Code") + }) + + it("start with SimpleX MCP and stay valid SimpleX names of at most 50 characters", () => { + expect(profileName("simplex-chat-6")).toBe("SimpleX MCP - simplex-chat-6") + expect(profileName("a".repeat(80))).toBe(`SimpleX MCP - ${"a".repeat(36)}`) + expect(profileName("fix #12, then @bob")).toBe("SimpleX MCP - fix 12 then bob") + }) +})