mcp: open session agents and the hub

This commit is contained in:
shum committed 2026-10-24 13:56:33 +00:00
1 parent 18a014b88b
commit 6de05718c8
3 files changed
+183

No files matched your search

+72
View File
@@ -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<void>
close(): Promise<void>
}
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<SessionAgent> {
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
}
+96
View File
@@ -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<void>
export class Hub {
private stopped = false
private controlEvents: Promise<void> = 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<Hub> {
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<void> {
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<string> {
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<number> {
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<void> {
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))
}
}
}
@@ -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")
})
})