chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
+67
View File
@@ -0,0 +1,67 @@
# @mosaicstack/discord-plugin
Official Discord channel adapter for Mosaic Stack.
## Behavior
- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness.
- Routes authorized untagged messages in configured agent channels and replies in-channel.
- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message.
- Keeps follow-ups in existing threads without requiring repeated mentions.
- Keeps `/approve` and `/stop <approval>` on the current durable session.
- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch.
- Authenticates to the gateway and signs ingress envelopes with the injected service token.
## Required configuration
| Variable | Purpose |
| --------------------------------------- | -------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot credential |
| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential |
| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal |
| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist |
| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist |
| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles |
| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` |
Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets.
Example binding shape (identifiers are placeholders):
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "agent-config-id",
"guildId": "guild-id",
"channelId": "channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "mosaic-user-id"
}
}
}
]
```
Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`.
The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel.
## Shared contract
The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK.
## Development
```bash
pnpm --filter @mosaicstack/types build
pnpm --filter @mosaicstack/discord-plugin typecheck
pnpm --filter @mosaicstack/discord-plugin lint
pnpm --filter @mosaicstack/discord-plugin test
pnpm --filter @mosaicstack/discord-plugin build
```
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@mosaicstack/discord-plugin",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/discord"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@mosaicstack/types": "workspace:^",
"discord.js": "^14.16.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+950
View File
@@ -0,0 +1,950 @@
import { EventEmitter } from 'node:events';
import type {
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelIngressDto,
ChannelIngressPort,
} from '@mosaicstack/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
verifyDiscordIngressEnvelope,
type DiscordIngressEnvelope,
type DiscordInteractionRole,
type DiscordPluginConfig,
} from './index.js';
const SERVICE_TOKEN = 'test-service-token';
interface FakeDiscordMessageOptions {
id?: string;
guildId?: string;
content: string;
mentioned?: boolean;
userId?: string;
channelId?: string;
parentChannelId?: string | null;
isThread?: boolean;
hasThread?: boolean;
existingThreadId?: string;
fetchedThreadId?: string;
createdThreadId?: string;
attachments?: Map<string, FakeDiscordAttachment>;
}
interface FakeDiscordAttachment {
id: string;
name: string;
url: string;
contentType: string | null;
size?: number;
}
interface FakeDiscordMessage {
id: string;
guildId: string;
channelId: string;
author: { id: string; bot: boolean };
mentions: { has(user: { id: string }): boolean };
content: string;
createdAt: Date;
channel: {
parentId: string | null;
isThread(): boolean;
threads?: { fetch(id: string): Promise<{ id: string }> };
};
attachments: Map<string, FakeDiscordAttachment>;
hasThread: boolean;
thread: { id: string } | null;
startThread: ReturnType<typeof vi.fn>;
}
interface FakeGatewaySocket extends EventEmitter {
connected: boolean;
disconnect?: ReturnType<typeof vi.fn>;
}
interface FakeLifecycleClient extends EventEmitter {
user: { id: string; tag: string };
login: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
isReady(): boolean;
guilds: { cache: Map<string, object> };
channels: { cache: Map<string, object> };
}
interface DiscordPluginInternals {
client: {
user: { id: string };
isReady(): boolean;
channels?: {
cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
};
};
socket: {
connected: boolean;
emit: ReturnType<typeof vi.fn>;
} | null;
conversationRoutes: Map<string, ChannelConversationRouteDto>;
handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
sendToDiscord(conversationId: string, text: string): Promise<void>;
}
function lifecyclePlugin(): {
plugin: DiscordPlugin;
socket: FakeGatewaySocket;
client: FakeLifecycleClient;
} {
const socket = Object.assign(new EventEmitter(), {
connected: false,
disconnect: vi.fn(),
});
const client = Object.assign(new EventEmitter(), {
user: { id: 'bot-001', tag: 'mosaic-bot' },
login: vi.fn().mockResolvedValue('token'),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: (): boolean => true,
guilds: { cache: new Map<string, object>() },
channels: { cache: new Map<string, object>() },
});
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://gateway.invalid',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' },
},
},
],
},
{
client: client as never,
socketFactory: vi.fn().mockReturnValue(socket) as never,
},
);
return { plugin, socket, client };
}
function createPlugin(
role: DiscordInteractionRole = 'operator',
paired = true,
ingressPort?: ChannelIngressPort,
configOverrides: Partial<DiscordPluginConfig> = {},
): {
plugin: DiscordPlugin;
internals: DiscordPluginInternals;
emit: ReturnType<typeof vi.fn>;
} {
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: paired
? {
'user-001': { role, mosaicUserId: 'mosaic-user-001' },
}
: {},
},
],
...configOverrides,
},
{ ingressPort },
);
const emit = vi.fn();
const internals = plugin as unknown as DiscordPluginInternals;
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true };
internals.socket = { connected: true, emit };
return { plugin, internals, emit };
}
function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage {
const channelId = options.channelId ?? 'channel-001';
const parentChannelId = options.parentChannelId;
const existingThreadId = options.existingThreadId;
return {
id: options.id ?? 'message-001',
guildId: options.guildId ?? 'guild-001',
channelId,
author: { id: options.userId ?? 'user-001', bot: false },
mentions: { has: (): boolean => options.mentioned ?? false },
content: options.content,
createdAt: new Date('2026-07-14T12:00:00.000Z'),
channel: {
parentId: parentChannelId ?? null,
isThread: (): boolean =>
options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null),
...(options.fetchedThreadId
? {
threads: {
fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }),
},
}
: {}),
},
attachments: options.attachments ?? new Map<string, FakeDiscordAttachment>(),
hasThread: options.hasThread ?? existingThreadId !== undefined,
thread: existingThreadId ? { id: existingThreadId } : null,
startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
};
}
function emittedEnvelope(emit: ReturnType<typeof vi.fn>): DiscordIngressEnvelope {
const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined;
expect(call?.[0]).toBe('message');
expect(call?.[1]).toBeDefined();
return (
call?.[1] ??
createDiscordIngressEnvelope(
{
correlationId: 'unreachable',
messageId: 'unreachable',
guildId: 'unreachable',
channelId: 'unreachable',
userId: 'unreachable',
conversationId: 'unreachable',
content: 'unreachable',
},
SERVICE_TOKEN,
)
);
}
afterEach((): void => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('Discord binding configuration', () => {
it('parses role-only and Mosaic-linked pairings', () => {
const bindings = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'legacy-user': 'operator',
'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' },
'linked-without-id': { role: 'operator' },
},
},
]),
);
expect(bindings).toHaveLength(1);
expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001');
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull();
expect(
resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'guild-001',
'channel-001',
'linked-without-id',
'attach',
),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'other-guild',
'channel-001',
'linked-user',
'send',
),
).toBeNull();
});
it.each([
['missing value', undefined],
['empty array', '[]'],
['non-object binding', '[null]'],
['missing binding fields', '[{"instanceId":"Nova"}]'],
[
'empty Discord user ID',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]',
],
[
'invalid role-only pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]',
],
[
'non-object pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]',
],
[
'invalid linked pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]',
],
])('rejects %s', (_case: string, value: string | undefined) => {
expect(() => parseDiscordInteractionBindings(value)).toThrow();
});
});
describe('Discord ingress integrity', () => {
it.each([
['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }],
['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }],
['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }],
])('rejects an unallowlisted %s', (_field: string, allowlists) => {
const payload = {
correlationId: 'correlation-001',
messageId: 'message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'Nova:discord:channel-001',
content: 'hello',
};
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull();
});
});
describe('Discord adapter lifecycle', () => {
it('starts while the in-process gateway is still connecting, then reports connected', async () => {
const { plugin, socket, client } = lifecyclePlugin();
await plugin.start();
expect(client.login).toHaveBeenCalledWith('unused');
expect(await plugin.health()).toEqual({ status: 'degraded' });
socket.connected = true;
socket.emit('connect');
expect(await plugin.health()).toEqual({ status: 'connected' });
await plugin.stop();
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
it('keeps running while Socket.IO reconnects after an initial gateway error', async () => {
const { plugin, socket } = lifecyclePlugin();
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
await plugin.start();
socket.emit('connect_error', new Error('gateway not listening yet'));
expect(error).toHaveBeenCalledWith(
'[discord] Gateway connection error: gateway not listening yet',
);
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
it('cleans up when Discord login fails', async () => {
const { plugin, socket, client } = lifecyclePlugin();
client.login.mockRejectedValueOnce(new Error('Discord authentication rejected'));
await expect(plugin.start()).rejects.toThrow('Discord authentication rejected');
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
});
describe('Discord project channel provisioning', () => {
it('returns null without a configured guild or visible guild', async () => {
const { plugin } = createPlugin();
await expect(
plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
const { client } = lifecyclePlugin();
const configured = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'missing-guild',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
});
it('creates a normalized Discord project channel', async () => {
const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' });
const { client } = lifecyclePlugin();
client.guilds.cache.set('guild-001', { channels: { create } });
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'guild-001',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
plugin.createProjectChannel({
id: 'project-1',
name: ' Project Alpha! ',
description: 'Alpha workspace',
}),
).resolves.toEqual({ channelId: 'created-channel-001' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }),
);
await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' });
expect(create).toHaveBeenLastCalledWith(
expect.objectContaining({ topic: 'Mosaic project: Beta' }),
);
});
});
function egressFor(
route: ChannelConversationRouteDto,
content = 'agent response',
): ChannelEgressDto {
return {
correlationId: 'egress-correlation-001',
route,
message: {
id: 'egress-message-001',
channelName: 'discord',
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content,
contentKind: 'markdown',
timestamp: '2026-07-14T12:00:00.000Z',
metadata: {},
},
};
}
describe('official Discord channel routing', () => {
it('normalizes an authorized turn through the shared channel ingress port', async () => {
const receive = vi.fn<(ingress: ChannelIngressDto) => Promise<void>>().mockResolvedValue();
const { internals, emit } = createPlugin('operator', true, { receive });
internals.socket = null;
await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' }));
expect(emit).not.toHaveBeenCalled();
expect(receive).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'message.send',
principal: {
channelUserId: 'user-001',
role: 'operator',
mosaicUserId: 'mosaic-user-001',
},
message: expect.objectContaining({
channelName: 'discord',
channelId: 'channel-001',
content: 'normalized turn',
senderKind: 'user',
}),
route: expect.objectContaining({
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
}),
}),
);
});
it('releases a response route when typed ingress rejects', async () => {
const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress'));
const { internals } = createPlugin('operator', true, { receive });
await expect(
internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })),
).rejects.toThrow('gateway rejected ingress');
expect(internals.conversationRoutes).toHaveLength(0);
});
it('routes an authorized untagged parent-channel message and responds in that channel', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: 'channel conversation' });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
content: 'channel conversation',
});
expect(payload?.threadId).toBeUndefined();
});
it('creates a thread for a mentioned parent-channel message and routes the response there', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> investigate this topic',
mentioned: true,
createdThreadId: 'thread-created-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).toHaveBeenCalledOnce();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-created-001',
content: 'investigate this topic',
threadId: 'thread-created-001',
});
});
it('fetches and reuses an existing thread that is missing from the cache', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue uncached topic',
mentioned: true,
hasThread: true,
fetchedThreadId: 'thread-fetched-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-fetched-001',
threadId: 'thread-fetched-001',
});
});
it('delivers the agent response to the thread selected by the mentioned turn', async () => {
const { internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (id: string): { send(options: unknown): Promise<void> } | undefined =>
id === 'thread-response-001' ? { send } : undefined,
},
},
};
await internals.handleDiscordMessage(
fakeMessage({
content: '<@bot-001> threaded response',
mentioned: true,
createdThreadId: 'thread-response-001',
}),
);
await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ content: 'agent answer', enforceNonce: true }),
);
});
it('reuses a thread already attached to a mentioned message instead of creating another', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue existing topic',
mentioned: true,
existingThreadId: 'thread-existing-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-existing-001',
threadId: 'thread-existing-001',
});
});
it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'thread follow-up',
channelId: 'thread-001',
parentChannelId: 'channel-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-001',
content: 'thread follow-up',
threadId: 'thread-001',
});
});
it.each([
['guild', { guildId: 'guild-not-allowed' }],
['channel', { channelId: 'channel-not-allowed' }],
['user', { userId: 'user-not-allowed' }],
])(
'rejects an unauthorized %s before thread creation or gateway dispatch',
async (_boundary: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> unauthorized topic',
mentioned: true,
...override,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }],
])(
'rejects an allowlisted but unpaired user in %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin('operator', false);
const message = fakeMessage({ content: 'unpaired message', ...override });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('rejects a paired viewer before thread creation or gateway dispatch', async () => {
const { internals, emit } = createPlugin('viewer');
const message = fakeMessage({
content: '<@bot-001> viewer cannot send',
mentioned: true,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('uses a normal channel ID rather than its category parent for authorization', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'message from categorized channel',
parentChannelId: 'category-001',
isThread: false,
});
await internals.handleDiscordMessage(message);
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
});
});
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }],
])(
'preserves an attachment-only turn in an authorized %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const attachments = new Map<string, FakeDiscordAttachment>([
[
'attachment-001',
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.invalid/diagram.png',
contentType: 'image/png',
size: 4_096,
},
],
]);
await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override }));
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
content: '',
attachments: [
{
id: 'attachment-001',
name: 'diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
},
],
});
},
);
it('does not dispatch when Discord cannot create the requested thread', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true });
message.startThread.mockRejectedValueOnce(new Error('missing thread permission'));
await expect(internals.handleDiscordMessage(message)).rejects.toThrow(
'missing thread permission',
);
expect(emit).not.toHaveBeenCalled();
});
it.each(['/approve', '/stop approval-001'])(
'rejects operator use of privileged command %s before gateway dispatch',
async (command: string) => {
const { internals, emit } = createPlugin('operator');
const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('keeps runtime control commands on the current durable session', async () => {
const { internals, emit } = createPlugin('admin');
const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
});
it('keeps the stable conversation address independent of a runtime harness', async () => {
const first = createPlugin();
const second = createPlugin();
await first.internals.handleDiscordMessage(
fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }),
);
await second.internals.handleDiscordMessage(
fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }),
);
const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN);
const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN);
expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001');
expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId);
});
it('rate-limits authorized turns before thread creation or dispatch', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 1,
threadRateLimitPerMinute: 1,
});
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'rate-first', content: 'first turn' });
const second = fakeMessage({
id: 'rate-second',
content: '<@bot-001> second turn',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(emit).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached'));
});
it('applies a stricter mention-thread rate limit before Discord side effects', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 10,
threadRateLimitPerMinute: 1,
});
vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true });
const second = fakeMessage({
id: 'thread-rate-second',
content: 'second topic',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(first.startThread).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledOnce();
});
it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => {
const { plugin, internals } = createPlugin();
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await expect(
plugin.send(
egressFor({
...route,
bindingId: 'forged-binding',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:unrelated-conversation',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send({
...egressFor(route),
message: { ...egressFor(route).message, channelId: 'other-channel' },
}),
).rejects.toMatchObject({ code: 'invalid_route' });
const forgedSend = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: forgedSend }),
},
},
};
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:forged-channel',
responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' },
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
expect(forgedSend).not.toHaveBeenCalled();
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: { cache: { get: (): undefined => undefined } },
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'destination_unavailable',
});
await expect(
internals.sendToDiscord('missing-conversation', 'missing route'),
).rejects.toMatchObject({ code: 'invalid_route' });
vi.useFakeTimers();
const send = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 }));
internals.client.channels = {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
};
const delivery = plugin.send(egressFor(route));
const rejection = expect(delivery).rejects.toMatchObject({
code: 'delivery_failed',
retryable: true,
});
await vi.runAllTimersAsync();
await rejection;
expect(send).toHaveBeenCalledTimes(3);
expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ enforceNonce: true, content: 'agent response' }),
);
const permanentSend = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 }));
internals.client.channels = {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: permanentSend }),
},
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'delivery_failed',
retryable: false,
});
expect(permanentSend).toHaveBeenCalledOnce();
});
it('chunks long egress responses at Discord-safe boundaries', async () => {
const { plugin, internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
},
};
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`));
expect(send).toHaveBeenCalledTimes(2);
});
it('reports channel adapter health without exposing runtime-provider state', async () => {
const { plugin, internals } = createPlugin();
expect(await plugin.health()).toEqual({ status: 'connected' });
internals.socket = { connected: false, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false };
expect(await plugin.health()).toEqual({ status: 'disconnected' });
internals.socket = { connected: true, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
});
+928
View File
@@ -0,0 +1,928 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelDeliveryError } from '@mosaicstack/types';
import type {
ChannelAdapterHealthDto,
ChannelAuthorizedPrincipalDto,
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelEgressPort,
ChannelIngressDto,
ChannelIngressPort,
ChannelMessageDto,
OfficialChannelAdapter,
} from '@mosaicstack/types';
import {
ChannelType,
Client,
GatewayIntentBits,
ThreadAutoArchiveDuration,
type Message as DiscordMessage,
} from 'discord.js';
import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginDependencies {
ingressPort?: ChannelIngressPort;
client?: Client;
socketFactory?: (url: string, options: Parameters<typeof io>[1]) => Socket;
}
export interface DiscordPluginConfig {
token: string;
gatewayUrl: string;
/** Maximum authorized turns per Discord user/channel per minute. */
messageRateLimitPerMinute?: number;
/** Maximum mention-triggered thread routes per Discord user/channel per minute. */
threadRateLimitPerMinute?: number;
/** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
guildId?: string;
allowedGuildIds: readonly string[];
allowedChannelIds: readonly string[];
allowedUserIds: readonly string[];
/** Provisioned interaction bindings; instance identity is configuration, never code. */
interactionBindings?: readonly DiscordInteractionBinding[];
}
export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
/** A provisioned Discord-to-Mosaic identity pairing. */
export interface DiscordInteractionUserBinding {
role: DiscordInteractionRole;
/** Required for privileged approval and stop operations. */
mosaicUserId?: string;
}
/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
export interface DiscordInteractionBinding {
/** Stable logical agent identity used in channel conversation routes. */
instanceId: string;
/** Trusted gateway database agent-config ID selected for this binding. */
agentConfigId: string;
guildId: string;
channelId: string;
/** Pairing roster keyed by Discord user ID. */
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
}
const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30;
const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const DELIVERY_MAX_ATTEMPTS = 3;
const DELIVERY_RETRY_BASE_MS = 50;
const MAX_CONVERSATION_ROUTES = 1_000;
const operationRoles: Readonly<
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = {
bind: ['admin'],
attach: ['operator', 'admin'],
send: ['operator', 'admin'],
approve: ['admin'],
stop: ['admin'],
};
/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
export function resolveDiscordInteractionBinding(
bindings: readonly DiscordInteractionBinding[],
guildId: string,
channelId: string,
userId: string,
operation: DiscordInteractionOperation,
): DiscordInteractionBinding | null {
const binding = bindings.find(
(candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
);
if (!binding) return null;
const pairing = binding.pairedUsers[userId];
const role = typeof pairing === 'string' ? pairing : pairing?.role;
return role && operationRoles[operation].includes(role) ? binding : null;
}
/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
export function resolveDiscordInteractionActorId(
binding: DiscordInteractionBinding,
discordUserId: string,
): string | null {
const pairing = binding.pairedUsers[discordUserId];
if (typeof pairing === 'string') return null;
const mosaicUserId = pairing?.mosaicUserId?.trim();
return mosaicUserId || null;
}
/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
export function parseDiscordInteractionBindings(
value: string | undefined,
): DiscordInteractionBinding[] {
if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
}
return parsed.map((binding: unknown): DiscordInteractionBinding => {
if (typeof binding !== 'object' || binding === null)
throw new Error('Invalid Discord interaction binding');
const candidate = binding as Partial<DiscordInteractionBinding>;
if (
!candidate.instanceId ||
!candidate.agentConfigId ||
!candidate.guildId ||
!candidate.channelId ||
!candidate.pairedUsers ||
typeof candidate.pairedUsers !== 'object'
) {
throw new Error('Invalid Discord interaction binding');
}
const pairedUsers = Object.fromEntries(
Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
if (!discordUserId.trim()) {
throw new Error('Invalid Discord interaction user binding');
}
if (typeof pairing === 'string') {
if (!['viewer', 'operator', 'admin'].includes(pairing)) {
throw new Error('Invalid Discord interaction user binding');
}
return [discordUserId, pairing];
}
if (typeof pairing !== 'object' || pairing === null) {
throw new Error('Invalid Discord interaction user binding');
}
const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
if (
!userBinding.role ||
!['viewer', 'operator', 'admin'].includes(userBinding.role) ||
(userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
) {
throw new Error('Invalid Discord interaction user binding');
}
return [
discordUserId,
{
role: userBinding.role,
...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
},
];
}),
) as Record<string, DiscordInteractionPairing>;
return {
instanceId: candidate.instanceId,
agentConfigId: candidate.agentConfigId,
guildId: candidate.guildId,
channelId: candidate.channelId,
pairedUsers,
};
});
}
export interface DiscordIngressPayload {
correlationId: string;
messageId: string;
guildId: string;
channelId: string;
userId: string;
conversationId: string;
content: string;
threadId?: string;
attachments?: readonly DiscordAttachment[];
}
export interface DiscordAttachment {
id: string;
name: string;
url: string;
contentType: string | null;
sizeBytes?: number;
}
export interface DiscordIngressEnvelope {
payload: DiscordIngressPayload;
signature: string;
}
export interface DiscordIngressAllowlists {
guildIds: readonly string[];
channelIds: readonly string[];
userIds: readonly string[];
}
function signedPayload(payload: DiscordIngressPayload): string {
return [
payload.correlationId,
payload.messageId,
payload.guildId,
payload.channelId,
payload.userId,
payload.conversationId,
payload.content,
payload.threadId ?? '',
JSON.stringify(payload.attachments ?? []),
].join('\n');
}
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
}
function isSignatureValid(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
return (
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
);
}
function includesId(allowedIds: readonly string[], id: string): boolean {
return allowedIds.includes(id);
}
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
export function createDiscordIngressEnvelope(
payload: DiscordIngressPayload,
serviceToken: string,
): DiscordIngressEnvelope {
return { payload, signature: signPayload(payload, serviceToken) };
}
/**
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
* Returns null instead of a partially trusted payload on every failure path.
*/
export function verifyDiscordIngressEnvelope(
envelope: DiscordIngressEnvelope,
serviceToken: string,
allowlists?: DiscordIngressAllowlists,
): DiscordIngressPayload | null {
const expectedSignature = signPayload(envelope.payload, serviceToken);
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
if (
allowlists &&
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
!includesId(allowlists.userIds, envelope.payload.userId))
) {
return null;
}
return envelope.payload;
}
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
readonly name = 'discord';
private client: Client;
private socket: Socket | null = null;
/** Bounded last-authorized routes for response-target egress validation. */
private conversationRoutes = new Map<string, ChannelConversationRouteDto>();
/** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>();
private readonly messageRateWindows = new Map<string, number[]>();
private readonly threadRateWindows = new Map<string, number[]>();
constructor(
private readonly config: DiscordPluginConfig,
private readonly dependencies: DiscordPluginDependencies = {},
) {
this.client =
dependencies.client ??
new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
}
async start(): Promise<void> {
const socketFactory = this.dependencies.socketFactory ?? io;
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'],
});
this.socket.on('connect', () => {
console.log('[discord] Connected to gateway');
});
this.socket.on('disconnect', (reason: string) => {
console.error(`[discord] Disconnected from gateway: ${reason}`);
this.pendingResponses.clear();
});
this.socket.on('connect_error', (err: Error) => {
console.error(`[discord] Gateway connection error: ${err.message}`);
});
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) {
this.pendingResponses.set(data.conversationId, pending + data.text);
}
});
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
this.pendingResponses.delete(data.conversationId);
if (text) {
this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
});
} else {
this.conversationRoutes.delete(data.conversationId);
}
});
this.socket.on('agent:start', (data: { conversationId: string }) => {
this.pendingResponses.set(data.conversationId, '');
});
this.socket.on('error', (data: { conversationId?: unknown }) => {
if (typeof data.conversationId !== 'string') return;
this.pendingResponses.delete(data.conversationId);
this.conversationRoutes.delete(data.conversationId);
});
this.client.on('messageCreate', (message: DiscordMessage) => {
void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => {
const errorName = error instanceof Error ? error.name : 'UnknownError';
console.error(
`[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`,
);
});
});
this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
});
try {
await this.client.login(this.config.token);
} catch (error: unknown) {
this.socket.disconnect();
this.socket = null;
await this.client.destroy();
throw error;
}
}
async stop(): Promise<void> {
this.socket?.disconnect();
await this.client.destroy();
}
async health(): Promise<ChannelAdapterHealthDto> {
const discordReady = this.client.isReady();
const gatewayConnected = this.socket?.connected === true;
if (discordReady && gatewayConnected) return { status: 'connected' };
if (discordReady || gatewayConnected) return { status: 'degraded' };
return { status: 'disconnected' };
}
async createProjectChannel(project: {
id: string;
name: string;
description?: string;
}): Promise<{ channelId: string } | null> {
if (!this.config.guildId) return null;
const guild = this.client.guilds.cache.get(this.config.guildId);
if (!guild) return null;
const channelName = `mosaic-${project.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`;
const channel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
topic: project.description ?? `Mosaic project: ${project.name}`,
});
// A project channel has no logical-agent conversation until a configured
// binding authorizes a message. Do not seed a legacy channel-only key.
return { channelId: channel.id };
}
private handleDiscordMessage(message: DiscordMessage): void | Promise<void> {
if (message.author.bot || !this.client.user || !message.guildId) return;
const authorizationChannelId = this.authorizationChannelId(message);
if (!this.isAllowedMessage(message, authorizationChannelId)) return;
const isMention = message.mentions.has(this.client.user);
const content = isMention
? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim()
: message.content.trim();
if (!content && message.attachments.size === 0) return;
const operation = this.interactionOperation(content);
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
authorizationChannelId,
message.author.id,
operation,
);
// Pairing and operation-specific role checks happen before thread creation
// or any gateway dispatch, including privileged control events.
if (!binding) return;
const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`;
if (
!this.consumeRateLimit(
this.messageRateWindows,
rateKey,
this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
const createThread = isMention && operation === 'send';
if (
createThread &&
!this.consumeRateLimit(
this.threadRateWindows,
rateKey,
this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
if (!this.dependencies.ingressPort && !this.socket?.connected) {
console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
);
return;
}
// Approval/stop commands act on the current durable session. They remain
// at the current channel/thread target rather than creating a new topic.
const route = this.resolveConversationRoute(
message,
binding,
authorizationChannelId,
createThread,
);
if (route instanceof Promise) {
return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> =>
this.dispatchDiscordIngress(message, content, operation, binding, resolved),
);
}
return this.dispatchDiscordIngress(message, content, operation, binding, route);
}
private dispatchDiscordIngress(
message: DiscordMessage,
content: string,
operation: 'send' | 'approve' | 'stop',
binding: DiscordInteractionBinding,
route: ChannelConversationRouteDto,
): void | Promise<void> {
const guildId = message.guildId;
if (!guildId) return;
if (operation === 'send') this.rememberConversationRoute(route);
const correlationId = randomUUID();
const ingress: ChannelIngressDto = {
correlationId,
nativeMessageId: message.id,
operation:
operation === 'approve'
? 'approval.create'
: operation === 'stop'
? 'session.stop'
: 'message.send',
principal: this.authorizedPrincipal(binding, message.author.id),
message: this.channelMessage(message, content, route),
route,
};
if (this.dependencies.ingressPort) {
return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => {
if (operation === 'send') this.conversationRoutes.delete(route.conversationId);
throw error;
});
}
const socket = this.socket;
if (!socket?.connected) {
console.error(
`[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.emitDiscordIngress(socket, ingress);
}
private authorizedPrincipal(
binding: DiscordInteractionBinding,
channelUserId: string,
): ChannelAuthorizedPrincipalDto {
const pairing = binding.pairedUsers[channelUserId];
if (!pairing) throw new Error('Authorized Discord pairing is unavailable');
if (typeof pairing === 'string') return { channelUserId, role: pairing };
return {
channelUserId,
role: pairing.role,
...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}),
};
}
private channelMessage(
message: DiscordMessage,
content: string,
route: ChannelConversationRouteDto,
): ChannelMessageDto {
const attachments = Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.size,
}));
const firstContentType = attachments[0]?.mimeType;
return {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: message.author.id,
senderKind: 'user',
content,
contentKind:
content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file',
timestamp:
message.createdAt instanceof Date
? message.createdAt.toISOString()
: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
...(attachments.length > 0 ? { attachments } : {}),
metadata: {
channelMessageId: message.id,
guildId: message.guildId ?? '',
},
};
}
private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void {
const envelope = createDiscordIngressEnvelope(
{
correlationId: ingress.correlationId,
messageId: ingress.nativeMessageId,
guildId: String(ingress.message.metadata['guildId'] ?? ''),
channelId: ingress.route.authorizationChannelId,
userId: ingress.principal.channelUserId,
conversationId: ingress.route.conversationId,
content: ingress.message.content,
...(ingress.route.responseTarget.threadId
? { threadId: ingress.route.responseTarget.threadId }
: {}),
attachments: ingress.message.attachments?.map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
contentType: attachment.mimeType,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
})),
},
this.config.serviceToken,
);
socket.emit(
ingress.operation === 'approval.create'
? 'discord:approve'
: ingress.operation === 'session.stop'
? 'discord:stop'
: 'message',
envelope,
);
}
private authorizationChannelId(message: DiscordMessage): string {
// A normal guild channel can itself have a category parent. Only Discord
// threads inherit authorization from a configured parent text channel.
const channel = message.channel as DiscordMessage['channel'] & {
isThread?: () => boolean;
parentId?: string | null;
};
const isThread =
typeof channel.isThread === 'function'
? channel.isThread()
: channel.parentId !== undefined && channel.parentId !== null;
return isThread && channel.parentId ? channel.parentId : message.channelId;
}
private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean {
const guildId = message.guildId;
return (
guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) &&
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
includesId(this.config.allowedUserIds, message.author.id)
);
}
private resolveConversationRoute(
message: DiscordMessage,
binding: DiscordInteractionBinding,
authorizationChannelId: string,
createThread: boolean,
): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> {
if (authorizationChannelId !== message.channelId) {
return this.createConversationRoute(
binding,
authorizationChannelId,
message.channelId,
message.channelId,
);
}
if (!createThread) {
return this.createConversationRoute(binding, authorizationChannelId, message.channelId);
}
if (message.hasThread) {
const cachedThread = message.thread;
if (cachedThread) {
return this.createConversationRoute(
binding,
authorizationChannelId,
cachedThread.id,
cachedThread.id,
);
}
if (!('threads' in message.channel)) {
return Promise.reject(new Error('Existing Discord thread manager is unavailable'));
}
return message.channel.threads
.fetch(message.id)
.then((thread): ChannelConversationRouteDto => {
if (!thread) throw new Error('Existing Discord thread is unavailable');
return this.createConversationRoute(
binding,
authorizationChannelId,
thread.id,
thread.id,
);
});
}
return message
.startThread({
name: `Mosaic conversation ${message.id.slice(-8)}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: 'Authorized Mosaic mention',
})
.then(
(thread): ChannelConversationRouteDto =>
this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id),
);
}
private createConversationRoute(
binding: DiscordInteractionBinding,
authorizationChannelId: string,
responseChannelId: string,
threadId?: string,
): ChannelConversationRouteDto {
// Recompute from configuration on every turn so a stale in-memory map can
// never carry a channel-only or differently bound agent identity forward.
const conversationId = `${binding.instanceId}:discord:${responseChannelId}`;
return {
bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`,
logicalAgentId: binding.instanceId,
conversationId,
channelName: this.name,
authorizationChannelId,
responseTarget: {
channelId: responseChannelId,
...(threadId ? { threadId } : {}),
},
};
}
private consumeRateLimit(
windows: Map<string, number[]>,
key: string,
limit: number,
now = Date.now(),
): boolean {
const active = (windows.get(key) ?? []).filter(
(timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
if (active.length >= Math.max(1, limit)) {
windows.set(key, active);
return false;
}
active.push(now);
windows.set(key, active);
return true;
}
private interactionOperation(content: string): 'send' | 'approve' | 'stop' {
if (/^\/approve$/i.test(content)) return 'approve';
if (/^\/stop\s+\S+/i.test(content)) return 'stop';
return 'send';
}
async send(egress: ChannelEgressDto): Promise<void> {
if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord egress route is not authorized for conversation ${egress.route.conversationId}`,
);
}
const channelId = egress.route.responseTarget.channelId;
const channel = this.client.channels.cache.get(channelId);
if (!channel || !('send' in channel)) {
throw new ChannelDeliveryError(
'destination_unavailable',
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
);
}
const chunks = this.chunkText(egress.message.content, 1900);
for (const [chunkIndex, chunk] of chunks.entries()) {
await this.sendChunkWithRetry(
channel as {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk,
egress.correlationId,
chunkIndex,
egress.route.conversationId,
);
}
}
private async sendChunkWithRetry(
channel: {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk: string,
correlationId: string,
chunkIndex: number,
conversationId: string,
): Promise<void> {
const nonce = createHash('sha256')
.update(`${correlationId}:${chunkIndex}`)
.digest('hex')
.slice(0, 25);
let lastError: unknown;
for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) {
try {
await channel.send({ content: chunk, nonce, enforceNonce: true });
return;
} catch (error: unknown) {
lastError = error;
const retryable = this.isTransientDeliveryError(error);
if (!retryable) {
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
false,
{ cause: error },
);
}
if (attempt < DELIVERY_MAX_ATTEMPTS) {
await new Promise<void>((resolve): void => {
setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1));
});
}
}
}
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
true,
{ cause: lastError },
);
}
private isTransientDeliveryError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const candidate = error as { status?: unknown; code?: unknown };
if (
typeof candidate.status === 'number' &&
(candidate.status === 429 || candidate.status >= 500)
) {
return true;
}
return (
typeof candidate.code === 'string' &&
['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code)
);
}
private async sendAgentResponse(conversationId: string, text: string): Promise<void> {
const route = this.conversationRoutes.get(conversationId);
if (!route) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord response route is unavailable for conversation ${conversationId}`,
);
}
try {
await this.send({
correlationId: randomUUID(),
route,
message: {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content: text,
contentKind: 'markdown',
timestamp: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
metadata: {},
},
});
} finally {
this.conversationRoutes.delete(conversationId);
}
}
/** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
await this.sendAgentResponse(conversationId, text);
}
private rememberConversationRoute(route: ChannelConversationRouteDto): void {
if (
!this.conversationRoutes.has(route.conversationId) &&
this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES
) {
throw new ChannelDeliveryError(
'delivery_failed',
'Discord has reached its active response-route limit',
true,
);
}
this.conversationRoutes.set(route.conversationId, route);
}
private isConfiguredRoute(route: ChannelConversationRouteDto): boolean {
if (
route.channelName !== this.name ||
route.conversationId !==
`${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}`
) {
return false;
}
const bindingMatches = (this.config.interactionBindings ?? []).some(
(binding): boolean =>
route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` &&
route.logicalAgentId === binding.instanceId &&
route.authorizationChannelId === binding.channelId,
);
if (!bindingMatches) return false;
if (
route.responseTarget.channelId === route.authorizationChannelId &&
route.responseTarget.threadId === undefined
) {
return true;
}
const observed = this.conversationRoutes.get(route.conversationId);
return (
observed?.bindingId === route.bindingId &&
observed.logicalAgentId === route.logicalAgentId &&
observed.authorizationChannelId === route.authorizationChannelId &&
observed.responseTarget.channelId === route.responseTarget.channelId &&
observed.responseTarget.threadId === route.responseTarget.threadId
);
}
private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean {
return (
egress.message.channelName === this.name &&
egress.message.channelId === egress.route.responseTarget.channelId &&
egress.message.threadId === egress.route.responseTarget.threadId
);
}
private chunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart();
}
return chunks;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
},
},
});
+61
View File
@@ -0,0 +1,61 @@
# MACP OpenClaw Plugin
This plugin registers a new OpenClaw ACP runtime backend named `macp`.
When OpenClaw calls `sessions_spawn(runtime: "macp")`, the plugin now writes the prompt to a brief file, queues a MACP controller task in `.mosaic/orchestrator/tasks.json`, triggers `mosaic-orchestrator-run --once`, polls `.mosaic/orchestrator/results/<task-id>.json`, and streams the resulting output back as ACP runtime events.
## Current behavior
- Supports ACP `mode: "oneshot"` only
- Accepts any `agentId` and maps it to the queued MACP task `runtime`
- Defaults queued tasks to `dispatch: "yolo"` and `runtime: "codex"` when no override is provided
- Rejects persistent ACP sessions
- Keeps `src/pi-bridge.ts` for future `dispatch: "pi"` support
## Install in OpenClaw
Add the plugin entry to your OpenClaw config:
```json
{
"plugins": ["~/src/mosaic-mono-v1/plugins/macp/src/index.ts"]
}
```
## Optional config
```json
{
"plugins": [
{
"source": "~/src/mosaic-mono-v1/plugins/macp/src/index.ts",
"config": {
"defaultModel": "openai/gpt-5-mini",
"systemPrompt": "You are Pi running via MACP.",
"timeoutMs": 300000,
"logDir": "~/.openclaw/state/macp",
"repoRoot": "~/src/mosaic-mono-v1",
"orchDir": "~/src/mosaic-mono-v1/.mosaic/orchestrator",
"defaultDispatch": "yolo",
"defaultRuntime": "codex"
}
}
]
}
```
## Runtime flow
1. OpenClaw ensures a oneshot `macp` session and preserves the requested `agentId`.
2. `runTurn` writes the turn prompt to `~/.mosaic/macp-oc/<session>-<request>.md`.
3. The plugin appends a pending MACP task to the configured orchestrator queue.
4. The plugin triggers `~/.config/mosaic/bin/mosaic-orchestrator-run --once` in the configured repo root.
5. The plugin polls for `.mosaic/orchestrator/results/<task-id>.json` and streams the result back to OpenClaw.
## Verification
```bash
pnpm --filter @mosaicstack/oc-macp-plugin typecheck || npx tsc --noEmit -p plugins/macp/tsconfig.json
pnpm prettier --write "plugins/macp/**/*.{ts,json,md}"
pnpm format:check
```
+44
View File
@@ -0,0 +1,44 @@
{
"id": "macp",
"name": "MACP Runtime",
"description": "Registers the macp ACP runtime backend and routes turns through the MACP controller queue.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultModel": {
"type": "string",
"description": "Default Pi model in provider/model format. Retained for Pi bridge compatibility. Defaults to openai/gpt-5-mini."
},
"systemPrompt": {
"type": "string",
"description": "Optional system prompt retained for Pi bridge compatibility."
},
"timeoutMs": {
"type": "number",
"minimum": 1,
"description": "Maximum turn runtime in milliseconds. Defaults to 300000."
},
"logDir": {
"type": "string",
"description": "Directory for plugin state/log files. Defaults to the plugin state dir."
},
"repoRoot": {
"type": "string",
"description": "Repository root containing .mosaic/orchestrator. Defaults to ~/src/mosaic-stack-new."
},
"orchDir": {
"type": "string",
"description": "Override for the orchestrator directory. Defaults to <repoRoot>/.mosaic/orchestrator."
},
"defaultDispatch": {
"type": "string",
"description": "Dispatch type written into queued MACP tasks. Defaults to yolo."
},
"defaultRuntime": {
"type": "string",
"description": "Fallback runtime when agentId is unavailable. Defaults to codex."
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@mosaicstack/oc-macp-plugin",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/macp"
},
"type": "module",
"main": "src/index.ts",
"description": "OpenClaw ACP runtime backend that routes sessions_spawn(runtime:\"macp\") to the Pi MACP runner.",
"openclaw": {
"extensions": [
"./src/index.ts"
]
},
"dependencies": {
"@mariozechner/pi-agent-core": "^0.63.1",
"@mariozechner/pi-ai": "^0.63.1",
"@sinclair/typebox": "^0.34.41"
},
"devDependencies": {
"openclaw": "*"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+68
View File
@@ -0,0 +1,68 @@
/**
* ACP Runtime type definitions.
*
* These mirror the OpenClaw plugin SDK AcpRuntime types.
* Defined locally so the plugin compiles without hardcoded SDK paths.
* The OC plugin loader provides the actual SDK at runtime.
*/
export interface AcpRuntimeCapabilities {
controls: string[];
}
export interface AcpRuntimeEnsureInput {
sessionKey: string;
agent: string;
mode: 'oneshot' | 'session';
cwd?: string;
}
export interface AcpRuntimeHandle {
sessionKey: string;
backend: string;
runtimeSessionName: string;
cwd: string;
backendSessionId: string;
agentSessionId: string;
}
export interface AcpRuntimeEvent {
type: 'text_delta' | 'status' | 'done' | 'error';
text?: string;
stream?: string;
tag?: string;
stopReason?: string;
message?: string;
}
export interface AcpRuntimeTurnInput {
handle: AcpRuntimeHandle;
text: string;
requestId: string;
signal?: AbortSignal;
}
export interface AcpRuntimeStatus {
summary: string;
backendSessionId: string;
agentSessionId: string;
details?: Record<string, unknown>;
}
export interface AcpRuntimeDoctorReport {
ok: boolean;
code?: string;
message: string;
details?: string[];
installCommand?: string;
}
export interface AcpRuntime {
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(): AcpRuntimeCapabilities;
getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus>;
doctor(): Promise<AcpRuntimeDoctorReport>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}
+102
View File
@@ -0,0 +1,102 @@
import { createRequire } from 'node:module';
import * as os from 'node:os';
import * as path from 'node:path';
import { MacpRuntime } from './macp-runtime.js';
// Resolve OC plugin SDK dynamically — works on any machine with openclaw installed globally
const ocRequire = createRequire(import.meta.url);
const sdkRoot = path.dirname(ocRequire.resolve('openclaw/dist/plugin-sdk/index.js'));
// Dynamic imports for runtime SDK functions
const { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } = (await import(
`${sdkRoot}/acp-runtime.js`
)) as {
registerAcpRuntimeBackend: (backend: {
id: string;
runtime: any;
healthy: () => boolean;
}) => void;
unregisterAcpRuntimeBackend: (id: string) => void;
};
type PluginConfig = {
defaultModel?: string;
systemPrompt?: string;
timeoutMs?: number;
logDir?: string;
repoRoot?: string;
orchDir?: string;
defaultDispatch?: string;
defaultRuntime?: string;
};
function expandHome(rawPath: string): string {
if (rawPath === '~') {
return os.homedir();
}
if (rawPath.startsWith('~/')) {
return path.join(os.homedir(), rawPath.slice(2));
}
return rawPath;
}
function resolveConfig(pluginConfig?: Record<string, unknown>, stateDir?: string) {
const config = (pluginConfig ?? {}) as PluginConfig;
const repoRoot = config.repoRoot?.trim()
? path.resolve(expandHome(config.repoRoot))
: path.resolve(os.homedir(), 'src', 'mosaic-stack');
return {
defaultModel: config.defaultModel?.trim() || 'openai/gpt-5-mini',
systemPrompt: config.systemPrompt ?? '',
timeoutMs:
typeof config.timeoutMs === 'number' &&
Number.isFinite(config.timeoutMs) &&
config.timeoutMs > 0
? config.timeoutMs
: 300_000,
stateDir: config.logDir?.trim()
? path.resolve(expandHome(config.logDir))
: (stateDir ?? process.cwd()),
repoRoot,
orchDir: config.orchDir?.trim()
? path.resolve(expandHome(config.orchDir))
: path.join(repoRoot, '.mosaic', 'orchestrator'),
defaultDispatch: config.defaultDispatch?.trim() || 'yolo',
defaultRuntime: config.defaultRuntime?.trim() || 'codex',
};
}
function createMacpRuntimeService(pluginConfig?: Record<string, unknown>) {
let runtime: MacpRuntime | null = null;
return {
id: 'macp-runtime',
async start(ctx: { stateDir: string; logger: { info: (msg: string) => void } }) {
const resolved = resolveConfig(pluginConfig, ctx.stateDir);
runtime = new MacpRuntime({
...resolved,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: 'macp',
runtime,
healthy: () => runtime !== null,
});
ctx.logger.info(
`macp runtime backend registered (defaultRuntime: ${resolved.defaultRuntime}, defaultDispatch: ${resolved.defaultDispatch}, timeoutMs: ${resolved.timeoutMs})`,
);
},
async stop() {
if (runtime) {
unregisterAcpRuntimeBackend('macp');
runtime = null;
}
},
};
}
export default function register(api: any) {
const service = createMacpRuntimeService(api.pluginConfig);
api.registerService(service);
}
+570
View File
@@ -0,0 +1,570 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { access, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntimeEnsureInput,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
} from './acp-runtime-types.js';
export interface MacpRuntimeConfig {
defaultModel: string;
systemPrompt: string;
timeoutMs: number;
stateDir: string;
repoRoot?: string;
orchDir?: string;
defaultDispatch?: string;
defaultRuntime?: string;
logger?: {
info?: (message: string) => void;
warn?: (message: string) => void;
};
}
type HandleState = {
name: string;
agent: string;
runtime: string;
cwd: string;
model: string;
systemPrompt: string;
timeoutMs: number;
};
type OrchestratorTask = {
id: string;
title: string;
status: 'pending';
dispatch: string;
runtime: string;
worktree: string;
brief_path: string;
_brief_temp_path: string;
timeout_seconds: number;
metadata: Record<string, unknown>;
};
type QueueFile = {
tasks: OrchestratorTask[];
};
type TaskGateResult = {
command?: string;
exit_code?: number;
type?: string;
};
type TaskResult = {
task_id: string;
status: string;
summary?: string;
error?: unknown;
escalation_reason?: unknown;
branch?: string | null;
pr?: string | null;
files_changed?: string[];
gate_results?: TaskGateResult[];
metadata?: Record<string, unknown>;
};
const MACP_CAPABILITIES: AcpRuntimeCapabilities = {
controls: [],
};
const DEFAULT_REPO_ROOT = '~/src/mosaic-stack';
const ORCHESTRATOR_RUN_PATH = '~/.config/mosaic/bin/mosaic-orchestrator-run';
const PI_RUNNER_PATH = path.join(
os.homedir(),
'src',
'mosaic-stack',
'tools',
'macp',
'dispatcher',
'pi_runner.ts',
);
function expandHome(rawPath: string): string {
if (rawPath === '~') {
return os.homedir();
}
if (rawPath.startsWith('~/')) {
return path.join(os.homedir(), rawPath.slice(2));
}
return rawPath;
}
function resolveRepoRoot(config: MacpRuntimeConfig): string {
return path.resolve(expandHome(config.repoRoot?.trim() || DEFAULT_REPO_ROOT));
}
function resolveOrchDir(config: MacpRuntimeConfig): string {
if (config.orchDir?.trim()) {
return path.resolve(expandHome(config.orchDir));
}
return path.join(resolveRepoRoot(config), '.mosaic', 'orchestrator');
}
function resolveOrchestratorRunPath(): string {
return path.resolve(expandHome(ORCHESTRATOR_RUN_PATH));
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function sanitizeSegment(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'task'
);
}
function encodeHandleState(state: HandleState): string {
return JSON.stringify(state);
}
function decodeHandleState(handle: AcpRuntimeHandle): HandleState {
const parsed = JSON.parse(handle.runtimeSessionName) as Partial<HandleState>;
if (
typeof parsed.name !== 'string' ||
typeof parsed.agent !== 'string' ||
typeof parsed.runtime !== 'string' ||
typeof parsed.cwd !== 'string' ||
typeof parsed.model !== 'string' ||
typeof parsed.systemPrompt !== 'string' ||
typeof parsed.timeoutMs !== 'number'
) {
throw new Error('Invalid MACP runtime handle state.');
}
return parsed as HandleState;
}
function toSessionName(input: AcpRuntimeEnsureInput): string {
return `${input.agent}-${input.sessionKey}`;
}
function createTaskId(sessionKey: string, requestId: string): string {
return `${sanitizeSegment(sessionKey)}-${sanitizeSegment(requestId)}-${randomUUID().slice(0, 8)}`;
}
function createTaskTitle(prompt: string): string {
const firstLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'MACP OpenClaw task').slice(0, 120);
}
function buildWorktreePath(repoRoot: string, taskId: string): string {
const repoName = path.basename(repoRoot);
return path.join(path.dirname(repoRoot), `${repoName}-worktrees`, `macp-oc-${taskId}`);
}
function nowIso(): string {
return new Date().toISOString();
}
function chunkText(text: string, chunkSize = 4000): string[] {
const normalized = text.trim();
if (!normalized) {
return [];
}
const chunks: string[] = [];
for (let index = 0; index < normalized.length; index += chunkSize) {
chunks.push(normalized.slice(index, index + chunkSize));
}
return chunks;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function toMessage(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value instanceof Error) {
return value.message;
}
if (value === null || value === undefined) {
return '';
}
return JSON.stringify(value, null, 2);
}
function abortError(): Error {
const error = new Error('MACP turn aborted.');
error.name = 'AbortError';
return error;
}
async function waitFor(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
throw abortError();
}
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timeout);
signal?.removeEventListener('abort', onAbort);
reject(abortError());
};
const timeout = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
await rename(tempPath, filePath);
}
async function loadQueue(tasksPath: string): Promise<QueueFile> {
try {
const raw = JSON.parse(await readFile(tasksPath, 'utf-8')) as unknown;
if (Array.isArray(raw)) {
return { tasks: raw as OrchestratorTask[] };
}
if (isRecord(raw) && Array.isArray(raw.tasks)) {
return { tasks: raw.tasks as OrchestratorTask[] };
}
throw new Error('tasks.json must contain a tasks array.');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return { tasks: [] };
}
throw error;
}
}
async function withFileLock<T>(
lockPath: string,
timeoutMs: number,
action: () => Promise<T>,
): Promise<T> {
const deadline = Date.now() + Math.max(5_000, Math.min(timeoutMs, 30_000));
while (true) {
try {
const handle = await open(lockPath, 'wx');
try {
return await action();
} finally {
await handle.close();
await rm(lockPath, { force: true });
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') {
throw error;
}
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for orchestrator queue lock: ${lockPath}`);
}
await waitFor(200);
}
}
}
async function appendTaskToQueue(
task: OrchestratorTask,
orchDir: string,
timeoutMs: number,
): Promise<void> {
const tasksPath = path.join(orchDir, 'tasks.json');
const lockPath = `${tasksPath}.lock`;
await mkdir(orchDir, { recursive: true });
await withFileLock(lockPath, timeoutMs, async () => {
const queue = await loadQueue(tasksPath);
queue.tasks.push(task);
await writeJsonAtomic(tasksPath, queue);
});
}
async function readOrchestratorConfig(orchDir: string): Promise<Record<string, unknown>> {
const configPath = path.join(orchDir, 'config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as unknown;
if (!isRecord(config)) {
throw new Error(`Invalid orchestrator config: ${configPath}`);
}
return config;
}
async function ensureOrchestratorReady(orchDir: string): Promise<void> {
const config = await readOrchestratorConfig(orchDir);
if (config.enabled !== true) {
throw new Error(`MACP orchestrator is disabled in ${path.join(orchDir, 'config.json')}.`);
}
}
function triggerController(repoRoot: string): void {
const child = spawn(
'bash',
['-lc', `cd ${shellQuote(repoRoot)} && ${shellQuote(resolveOrchestratorRunPath())} --once`],
{
detached: true,
stdio: 'ignore',
},
);
child.unref();
}
async function pollForResult(
resultPath: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<TaskResult> {
const deadline = Date.now() + Math.max(timeoutMs, 2_000);
while (Date.now() <= deadline) {
if (signal?.aborted) {
throw abortError();
}
try {
const raw = JSON.parse(await readFile(resultPath, 'utf-8')) as unknown;
if (!isRecord(raw) || typeof raw.task_id !== 'string' || typeof raw.status !== 'string') {
throw new Error(`Invalid MACP result payload: ${resultPath}`);
}
return raw as TaskResult;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && !(error instanceof SyntaxError)) {
throw error;
}
}
await waitFor(2_000, signal);
}
throw new Error(`Timed out waiting for MACP result: ${resultPath}`);
}
async function resolveResultOutput(result: TaskResult, orchDir: string): Promise<string> {
const metadata = isRecord(result.metadata) ? result.metadata : {};
const outputCandidates = [metadata.result_output_path, metadata.output_path]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => (path.isAbsolute(value) ? value : path.resolve(orchDir, value)));
for (const candidate of outputCandidates) {
try {
return (await readFile(candidate, 'utf-8')).trim();
} catch {
// Fall back to formatted result details below.
}
}
const lines: string[] = [];
if (result.summary) {
lines.push(result.summary);
}
if (result.error) {
lines.push(`Error: ${toMessage(result.error)}`);
}
if (result.escalation_reason) {
lines.push(`Escalation: ${toMessage(result.escalation_reason)}`);
}
if (result.branch) {
lines.push(`Branch: ${result.branch}`);
}
if (result.pr) {
lines.push(`PR: ${result.pr}`);
}
if (Array.isArray(result.files_changed) && result.files_changed.length > 0) {
lines.push(`Files changed:\n${result.files_changed.map((file) => `- ${file}`).join('\n')}`);
}
if (Array.isArray(result.gate_results) && result.gate_results.length > 0) {
lines.push(
`Quality gates:\n${result.gate_results
.map((gate) => `- [${gate.exit_code ?? 0}] ${gate.command ?? 'unknown command'}`)
.join('\n')}`,
);
}
return lines.join('\n\n').trim() || JSON.stringify(result, null, 2);
}
export class MacpRuntime implements AcpRuntime {
constructor(private readonly config: MacpRuntimeConfig) {}
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
if (input.mode !== 'oneshot') {
throw new Error(`macp runtime only supports oneshot sessions; received "${input.mode}".`);
}
const cwd = path.resolve(input.cwd ?? process.cwd());
const state: HandleState = {
name: toSessionName(input),
agent: input.agent,
runtime: input.agent || this.config.defaultRuntime || 'codex',
cwd,
model: this.config.defaultModel,
systemPrompt: this.config.systemPrompt,
timeoutMs: this.config.timeoutMs,
};
return {
sessionKey: input.sessionKey,
backend: 'macp',
runtimeSessionName: encodeHandleState(state),
cwd,
backendSessionId: state.name,
agentSessionId: state.name,
};
}
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
const state = decodeHandleState(input.handle);
const repoRoot = resolveRepoRoot(this.config);
const orchDir = resolveOrchDir(this.config);
const taskId = createTaskId(input.handle.sessionKey, input.requestId);
const briefDir = path.join(os.homedir(), '.mosaic', 'macp-oc');
const briefPath = path.join(briefDir, `${state.name}-${input.requestId}.md`);
const resultPath = path.join(orchDir, 'results', `${taskId}.json`);
try {
await access(resolveOrchestratorRunPath());
await ensureOrchestratorReady(orchDir);
await mkdir(briefDir, { recursive: true });
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(briefPath, `${input.text.trimEnd()}\n`, 'utf-8');
const task: OrchestratorTask = {
id: taskId,
title: createTaskTitle(input.text),
status: 'pending',
dispatch: this.config.defaultDispatch || 'yolo',
runtime: state.runtime || this.config.defaultRuntime || 'codex',
worktree: buildWorktreePath(repoRoot, taskId),
brief_path: briefPath,
_brief_temp_path: briefPath,
timeout_seconds: Math.max(1, Math.ceil(state.timeoutMs / 1_000)),
metadata: {
source: 'openclaw-macp-plugin',
created_at: nowIso(),
session_key: input.handle.sessionKey,
request_id: input.requestId,
agent_id: state.agent,
cwd: state.cwd,
},
};
this.config.logger?.info?.(
`Queueing MACP orchestrator task ${taskId} (${task.runtime}/${task.dispatch}).`,
);
yield {
type: 'status',
text: `Queued MACP task ${taskId}.`,
tag: 'session_info_update',
};
await appendTaskToQueue(task, orchDir, state.timeoutMs);
triggerController(repoRoot);
const result = await pollForResult(resultPath, state.timeoutMs, input.signal);
const output = await resolveResultOutput(result, orchDir);
for (const chunk of chunkText(output)) {
yield {
type: 'text_delta',
text: chunk,
stream: 'output',
tag: 'agent_message_chunk',
};
}
yield {
type: 'done',
stopReason: result.status,
};
} catch (error) {
yield {
type: 'error',
message: error instanceof Error ? error.message : String(error),
};
} finally {
await rm(briefPath, { force: true }).catch(() => undefined);
}
}
getCapabilities(): AcpRuntimeCapabilities {
return MACP_CAPABILITIES;
}
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
const state = decodeHandleState(input.handle);
return {
summary: 'macp controller oneshot runtime ready',
backendSessionId: state.name,
agentSessionId: state.name,
details: {
mode: 'oneshot',
agent: state.agent,
runtime: state.runtime,
cwd: state.cwd,
repoRoot: resolveRepoRoot(this.config),
orchDir: resolveOrchDir(this.config),
},
};
}
async doctor(): Promise<AcpRuntimeDoctorReport> {
try {
const repoRoot = resolveRepoRoot(this.config);
const orchDir = resolveOrchDir(this.config);
const orchestratorRunPath = resolveOrchestratorRunPath();
await access(orchestratorRunPath);
await access(repoRoot);
await access(orchDir);
await access(PI_RUNNER_PATH).catch(() => undefined);
const orchestratorConfig = await readOrchestratorConfig(orchDir);
if (orchestratorConfig.enabled !== true) {
return {
ok: false,
code: 'MACP_ORCH_DISABLED',
message: 'MACP orchestrator is disabled for the configured repo.',
details: [path.join(orchDir, 'config.json')],
};
}
return {
ok: true,
message: 'MACP runtime is ready.',
details: [orchestratorRunPath, repoRoot, orchDir],
};
} catch (error) {
return {
ok: false,
code: 'MACP_ORCH_MISSING',
message: error instanceof Error ? error.message : String(error),
installCommand: 'pnpm install --frozen-lockfile',
};
}
}
async cancel(_input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
this.config.logger?.info?.('macp runtime cancel requested');
}
async close(_input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
this.config.logger?.info?.('macp runtime close requested');
}
}
+492
View File
@@ -0,0 +1,492 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
runAgentLoop,
type AgentContext,
type AgentEvent,
type AgentLoopConfig,
type AgentMessage,
type AgentTool,
} from '@mariozechner/pi-agent-core';
import {
getModel,
Type,
type AssistantMessage,
type AssistantMessageEvent,
type Model,
type Static,
} from '@mariozechner/pi-ai';
const execFileAsync = promisify(execFile);
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export interface PiBridgeOptions {
model: string;
systemPrompt: string;
prompt: string;
workDir: string;
timeoutMs: number;
logPath: string;
signal?: AbortSignal;
onEvent?: (event: AgentEvent) => void | Promise<void>;
}
export interface PiBridgeResult {
exitCode: number;
output: string;
messages: AgentMessage[];
tokenUsage: { input: number; output: number };
stopReason: string;
}
type TranscriptEvent = {
timestamp: string;
type: string;
data?: JsonValue;
};
function nowIso(): string {
return new Date().toISOString();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function asJsonValue(value: unknown): JsonValue {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => asJsonValue(item));
}
if (isRecord(value)) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, asJsonValue(item)]));
}
return String(value);
}
function resolvePath(workDir: string, targetPath: string): string {
if (path.isAbsolute(targetPath)) {
return path.normalize(targetPath);
}
return path.resolve(workDir, targetPath);
}
async function runCommand(
command: string,
workDir: string,
timeoutMs: number,
): Promise<{ stdout: string; stderr: string }> {
const result = await execFileAsync('bash', ['-lc', command], {
cwd: workDir,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
timeout: Math.max(1, timeoutMs),
});
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
function extractText(message: AgentMessage | undefined): string {
if (
!message ||
!('role' in message) ||
message.role !== 'assistant' ||
!Array.isArray(message.content)
) {
return '';
}
return message.content
.filter(
(part): part is { type: 'text'; text: string } =>
isRecord(part) && part.type === 'text' && typeof part.text === 'string',
)
.map((part) => part.text)
.join('\n')
.trim();
}
function getFinalAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined {
return [...messages]
.reverse()
.find(
(message): message is AssistantMessage => 'role' in message && message.role === 'assistant',
);
}
function resolveModel(modelRef: string): Model<any> {
const slashIndex = modelRef.indexOf('/');
if (slashIndex < 1) {
throw new Error(`Invalid Pi model "${modelRef}". Expected provider/model.`);
}
const provider = modelRef.slice(0, slashIndex);
const modelId = modelRef.slice(slashIndex + 1);
if (!modelId) {
throw new Error(`Invalid Pi model "${modelRef}". Expected provider/model.`);
}
const isOpenAiOAuth =
provider === 'openai' && (process.env.OPENAI_API_KEY?.startsWith('eyJ') ?? false);
try {
const model = getModel(provider as never, modelId as never);
if (isOpenAiOAuth && model.api === 'openai-responses') {
return { ...model, api: 'openai-completions' };
}
return model;
} catch {
const fallbackApi =
provider === 'anthropic'
? 'anthropic-messages'
: provider === 'openai'
? isOpenAiOAuth
? 'openai-completions'
: 'openai-responses'
: 'openai-completions';
return {
id: modelId,
name: modelId,
api: fallbackApi,
provider,
baseUrl: '',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 16384,
};
}
}
function createDefaultTools(workDir: string): AgentTool<any>[] {
const readFileSchema = Type.Object({
path: Type.String({ description: 'Relative or absolute path to read.' }),
});
const writeFileSchema = Type.Object({
path: Type.String({ description: 'Relative or absolute path to write.' }),
content: Type.String({ description: 'UTF-8 file content.' }),
append: Type.Optional(Type.Boolean({ description: 'Append instead of overwrite.' })),
});
const editFileSchema = Type.Object({
path: Type.String({ description: 'Relative or absolute path to edit.' }),
search: Type.String({ description: 'The exact text to replace.' }),
replace: Type.String({ description: 'Replacement text.' }),
replaceAll: Type.Optional(Type.Boolean({ description: 'Replace every occurrence.' })),
});
const execShellSchema = Type.Object({
command: Type.String({ description: 'Shell command to execute in the worktree.' }),
timeoutMs: Type.Optional(
Type.Number({ description: 'Optional timeout override in milliseconds.' }),
),
});
const listDirSchema = Type.Object({
path: Type.Optional(Type.String({ description: 'Directory path relative to the worktree.' })),
});
const gitSchema = Type.Object({
args: Type.Array(Type.String({ description: 'Git CLI argument.' }), {
description: 'Arguments passed to git.',
minItems: 1,
}),
});
const readFileTool: AgentTool<typeof readFileSchema> = {
name: 'read_file',
label: 'Read File',
description: 'Read a UTF-8 text file from disk.',
parameters: readFileSchema,
async execute(_toolCallId, params: Static<typeof readFileSchema>) {
const filePath = resolvePath(workDir, params.path);
const content = await fs.readFile(filePath, 'utf-8');
return {
content: [{ type: 'text', text: content }],
details: { path: filePath },
};
},
};
const writeFileTool: AgentTool<typeof writeFileSchema> = {
name: 'write_file',
label: 'Write File',
description: 'Write or append a UTF-8 text file.',
parameters: writeFileSchema,
async execute(_toolCallId, params) {
const filePath = resolvePath(workDir, params.path);
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (params.append) {
await fs.appendFile(filePath, params.content, 'utf-8');
} else {
await fs.writeFile(filePath, params.content, 'utf-8');
}
return {
content: [{ type: 'text', text: `Wrote ${filePath}` }],
details: { path: filePath, append: Boolean(params.append) },
};
},
};
const editFileTool: AgentTool<typeof editFileSchema> = {
name: 'edit_file',
label: 'Edit File',
description: 'Apply an exact-match text replacement to a UTF-8 text file.',
parameters: editFileSchema,
async execute(_toolCallId, params) {
const filePath = resolvePath(workDir, params.path);
const original = await fs.readFile(filePath, 'utf-8');
if (!original.includes(params.search)) {
throw new Error(`Search text not found in ${filePath}`);
}
const updated = params.replaceAll
? original.split(params.search).join(params.replace)
: original.replace(params.search, params.replace);
await fs.writeFile(filePath, updated, 'utf-8');
return {
content: [{ type: 'text', text: `Updated ${filePath}` }],
details: { path: filePath, replaceAll: Boolean(params.replaceAll) },
};
},
};
const execShellTool: AgentTool<typeof execShellSchema> = {
name: 'exec_shell',
label: 'Exec Shell',
description: 'Execute an unrestricted shell command inside the worktree.',
parameters: execShellSchema,
async execute(_toolCallId, params) {
const result = await runCommand(params.command, workDir, params.timeoutMs ?? 300_000);
return {
content: [
{
type: 'text',
text:
[result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n') ||
'(no output)',
},
],
details: { command: params.command, stdout: result.stdout, stderr: result.stderr },
};
},
};
const listDirTool: AgentTool<typeof listDirSchema> = {
name: 'list_dir',
label: 'List Dir',
description: 'List directory entries for a relative or absolute path.',
parameters: listDirSchema,
async execute(_toolCallId, params) {
const dirPath = resolvePath(workDir, params.path ?? '.');
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const lines = entries
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => `${entry.isDirectory() ? 'dir ' : 'file'} ${entry.name}`);
return {
content: [{ type: 'text', text: lines.join('\n') }],
details: { path: dirPath, entries: lines },
};
},
};
const gitTool: AgentTool<typeof gitSchema> = {
name: 'git',
label: 'Git',
description: 'Run git commands such as status, diff, add, or commit in the worktree.',
parameters: gitSchema,
async execute(_toolCallId, params) {
const result = await execFileAsync('git', params.args, {
cwd: workDir,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
timeout: 300_000,
});
const text =
[result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join('\n') || '(no output)';
return {
content: [{ type: 'text', text }],
details: { args: params.args, stdout: result.stdout ?? '', stderr: result.stderr ?? '' },
};
},
};
return [readFileTool, writeFileTool, editFileTool, execShellTool, listDirTool, gitTool];
}
function buildLogEntry(event: unknown): TranscriptEvent {
if (!isRecord(event) || typeof event.type !== 'string') {
return { timestamp: nowIso(), type: 'unknown', data: asJsonValue(event) };
}
const summary: Record<string, JsonValue> = {};
for (const [key, value] of Object.entries(event)) {
if (key === 'message' || key === 'toolResults' || key === 'messages') {
summary[key] = asJsonValue(value);
continue;
}
if (key !== 'type') {
summary[key] = asJsonValue(value);
}
}
return { timestamp: nowIso(), type: event.type, data: summary };
}
function inferExitCode(finalMessage: AssistantMessage | undefined, output: string): number {
if (!finalMessage) {
return 1;
}
if (finalMessage.stopReason === 'error' || finalMessage.stopReason === 'aborted') {
return 1;
}
if (/^(failed|failure|blocked)\b/i.test(output)) {
return 1;
}
return 0;
}
export function formatAssistantEvent(event: AssistantMessageEvent): {
text?: string;
stream?: 'output' | 'thought';
tag?: string;
} | null {
switch (event.type) {
case 'text_delta':
return { text: event.delta, stream: 'output', tag: 'agent_message_chunk' };
case 'thinking_delta':
return { text: event.delta, stream: 'thought', tag: 'agent_thought_chunk' };
case 'toolcall_start':
return {
text: JSON.stringify(event.partial.content[event.contentIndex] ?? {}),
tag: 'tool_call',
};
case 'toolcall_delta':
return { text: event.delta, tag: 'tool_call_update' };
case 'done':
return null;
case 'error':
return null;
default:
return null;
}
}
export async function runPiTurn(options: PiBridgeOptions): Promise<PiBridgeResult> {
const transcript: TranscriptEvent[] = [];
const workDir = path.resolve(options.workDir);
const logPath = path.resolve(options.logPath);
const timeoutController = new AbortController();
const combinedSignal = options.signal
? AbortSignal.any([options.signal, timeoutController.signal])
: timeoutController.signal;
const timeoutHandle = setTimeout(() => timeoutController.abort(), Math.max(1, options.timeoutMs));
const context: AgentContext = {
systemPrompt: options.systemPrompt,
messages: [],
tools: createDefaultTools(workDir),
};
const config: AgentLoopConfig = {
model: resolveModel(options.model),
reasoning: 'medium',
convertToLlm: async (messages) =>
messages.filter(
(message): message is AgentMessage =>
isRecord(message) &&
typeof message.role === 'string' &&
['user', 'assistant', 'toolResult'].includes(message.role),
),
};
const prompts: AgentMessage[] = [
{
role: 'user',
content: options.prompt,
timestamp: Date.now(),
},
];
try {
transcript.push({
timestamp: nowIso(),
type: 'runner_start',
data: {
model: options.model,
workDir,
timeoutMs: options.timeoutMs,
},
});
const messages = await runAgentLoop(
prompts,
context,
config,
async (event) => {
transcript.push(buildLogEntry(event));
await options.onEvent?.(event);
},
combinedSignal,
);
const finalMessage = getFinalAssistantMessage(messages);
const output = extractText(finalMessage);
const tokenUsage = finalMessage
? {
input: finalMessage.usage?.input ?? 0,
output: finalMessage.usage?.output ?? 0,
}
: { input: 0, output: 0 };
const result: PiBridgeResult = {
exitCode: inferExitCode(finalMessage, output),
output,
messages,
tokenUsage,
stopReason: finalMessage?.stopReason ?? 'stop',
};
transcript.push({
timestamp: nowIso(),
type: 'runner_end',
data: {
exitCode: result.exitCode,
output: result.output,
tokenUsage: result.tokenUsage,
stopReason: result.stopReason,
},
});
await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.writeFile(logPath, `${JSON.stringify({ transcript, result }, null, 2)}\n`, 'utf-8');
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
transcript.push({
timestamp: nowIso(),
type: 'runner_error',
data: { error: message },
});
await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.writeFile(
logPath,
`${JSON.stringify({ transcript, result: { exitCode: 1, output: message, tokenUsage: { input: 0, output: 0 }, stopReason: 'error' } }, null, 2)}\n`,
'utf-8',
);
return {
exitCode: 1,
output: message,
messages: [],
tokenUsage: { input: 0, output: 0 },
stopReason: combinedSignal.aborted ? 'aborted' : 'error',
};
} finally {
clearTimeout(timeoutHandle);
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../packages/config/typescript/library.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,34 @@
{
"id": "mosaic-framework",
"name": "Mosaic Framework",
"description": "Mechanically injects Mosaic rails and mission context into all agent sessions and ACP worker spawns. Ensures no worker starts without the framework contract.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"mosaicHome": {
"type": "string",
"description": "Path to the Mosaic config home (default: ~/.config/mosaic)"
},
"projectRoots": {
"type": "array",
"items": { "type": "string" },
"description": "List of project root paths to scan for active missions. Plugin checks each for .mosaic/orchestrator/mission.json."
},
"requireMission": {
"type": "boolean",
"description": "If true, ACP coding worker spawns are BLOCKED when no active Mosaic mission exists in any configured project root. Default: false."
},
"injectAgentIds": {
"type": "array",
"items": { "type": "string" },
"description": "Agent IDs that receive framework context via before_agent_start (appendSystemContext). Default: all agents."
},
"acpAgentIds": {
"type": "array",
"items": { "type": "string" },
"description": "ACP agent IDs that trigger runtime contract injection (subagent_spawning). Default: ['codex', 'claude']."
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@mosaicstack/oc-framework-plugin",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/mosaic-framework"
},
"type": "module",
"main": "src/index.ts",
"description": "Injects Mosaic framework rails, runtime contract, and active mission context into all OpenClaw agent sessions and ACP subagent spawns.",
"openclaw": {
"extensions": [
"./src/index.ts"
]
},
"devDependencies": {
"openclaw": "*"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+496
View File
@@ -0,0 +1,496 @@
/**
* mosaic-framework — OpenClaw Plugin
*
* Mechanically injects the Mosaic framework contract into every agent session
* and ACP coding worker spawn. Two injection paths:
*
* 1. before_agent_start (OC native sessions):
* Returns appendSystemContext with the Mosaic global contract excerpt
* + prependContext with active mission state (dynamic, re-read each turn).
*
* 2. subagent_spawning (ACP worker spawns — Codex, Claude, etc.):
* Writes the full runtime contract to ~/.codex/instructions.md
* (or Claude equivalent) BEFORE the external process starts.
* Optionally blocks spawns when no active mission exists.
*/
import os from 'node:os';
import path from 'node:path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
// OpenClawPluginApi type — the OC plugin loader provides the actual api object at runtime
type OpenClawPluginApi = any;
// ---------------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------------
interface MosaicFrameworkConfig {
mosaicHome?: string;
projectRoots?: string[];
requireMission?: boolean;
injectAgentIds?: string[];
acpAgentIds?: string[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function expandHome(p: string): string {
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
if (p === '~') return os.homedir();
return p;
}
function safeRead(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
const raw = safeRead(filePath);
if (!raw) return null;
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return null;
}
}
function safeReadNdjson(filePath: string, limit = 10): Record<string, unknown>[] {
const raw = safeRead(filePath);
if (!raw) return [];
const parsed: Record<string, unknown>[] = [];
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
const item = JSON.parse(line) as unknown;
if (typeof item === 'object' && item !== null) {
parsed.push(item as Record<string, unknown>);
}
} catch {
continue;
}
}
return parsed.slice(-limit);
}
// ---------------------------------------------------------------------------
// Mission detection
// ---------------------------------------------------------------------------
interface ActiveMission {
name: string;
id: string;
status: string;
projectRoot: string;
milestonesTotal: number;
milestonesCompleted: number;
}
function findActiveMission(projectRoots: string[]): ActiveMission | null {
for (const root of projectRoots) {
const expanded = expandHome(root);
const missionFile = path.join(expanded, '.mosaic/orchestrator/mission.json');
if (!existsSync(missionFile)) continue;
const data = safeReadJson(missionFile);
if (!data) continue;
const status = String(data.status ?? 'inactive');
if (status !== 'active' && status !== 'paused') continue;
const milestones = Array.isArray(data.milestones) ? data.milestones : [];
const completed = milestones.filter(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
(m as Record<string, unknown>).status === 'completed',
).length;
return {
name: String(data.name ?? 'unnamed'),
id: String(data.mission_id ?? ''),
status,
projectRoot: expanded,
milestonesTotal: milestones.length,
milestonesCompleted: completed,
};
}
return null;
}
function findMacpProjectRoot(projectRoots: string[]): string | null {
for (const root of projectRoots) {
const expanded = expandHome(root);
const configPath = path.join(expanded, '.mosaic/orchestrator/config.json');
const config = safeReadJson(configPath);
if (config?.enabled) {
return expanded;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Build runtime contract (mirrors mosaic's build_runtime_prompt codex)
// ---------------------------------------------------------------------------
function buildRuntimeContract(
mosaicHome: string,
mission: ActiveMission | null,
projectRoot?: string,
): string {
const sections: string[] = [];
// 1. Active mission block (FIRST — hard gate)
if (mission) {
sections.push(`# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** ${mission.name}
**ID:** ${mission.id}
**Status:** ${mission.status}
**Project Root:** ${mission.projectRoot}
**Milestones:** ${mission.milestonesCompleted} / ${mission.milestonesTotal} completed
## MANDATORY — Before ANY Response
You MUST complete these steps before ANY response, including simple greetings:
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history and decisions
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
5. Acknowledge mission state to the user before proceeding
No tool call or implementation step may occur before the mode declaration line.`);
}
// 2. Mosaic Runtime Contract (from ~/.config/mosaic/runtime/codex/RUNTIME.md)
const runtimeFile = path.join(mosaicHome, 'runtime/codex/RUNTIME.md');
const runtimeContent = safeRead(runtimeFile);
if (runtimeContent) {
sections.push(runtimeContent.trim());
}
// 3. Global AGENTS.md hard rules
const agentsFile = path.join(mosaicHome, 'AGENTS.md');
const agentsContent = safeRead(agentsFile);
if (agentsContent) {
// Extract just the hard rules section to keep the contract focused
const hardRulesMatch = agentsContent.match(/## ⛔ HARD RULES[\s\S]*?(?=^## (?!⛔)|\Z)/m);
if (hardRulesMatch) {
sections.push(`# Mosaic Global Agent Contract — Hard Rules\n\n${hardRulesMatch[0].trim()}`);
} else {
// Fallback: include first 200 lines
const lines = agentsContent.split('\n').slice(0, 200).join('\n');
sections.push(`# Mosaic Global Agent Contract\n\n${lines}`);
}
}
// 4. Mode declaration requirement
sections.push(`# Required Mode Declaration
First assistant response MUST start with exactly one mode declaration line:
- Orchestration mission: \`Now initiating Orchestrator mode...\`
- Implementation mission: \`Now initiating Delivery mode...\`
- Review-only mission: \`Now initiating Review mode...\`
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.`);
// 5. Worktree requirement (critical — has been violated repeatedly)
const projectName = projectRoot ? path.basename(projectRoot) : '<repo>';
sections.push(`# Git Worktree Requirement — MANDATORY
Every agent that touches a git repo MUST use a worktree. NO EXCEPTIONS.
\`\`\`bash
cd ~/src/${projectName}
git fetch origin
mkdir -p ~/src/${projectName}-worktrees
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
cd ~/src/${projectName}-worktrees/<task-slug>
pnpm install --frozen-lockfile --prefer-offline
# ... all work happens here ...
git push origin <branch-name>
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
\`\`\`
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
until deps are installed. That failure is indistinguishable from a real
test/lint failure — a false-red gate. Never skip this step and never reorder
it after the first gate invocation.`);
// 6. Completion gates
sections.push(`# Completion Gates — ENFORCED
A task is NOT done until ALL of these pass:
1. Code review — independent review of every changed file
2. Security review — auth, input validation, error leakage
3. QA/tests — lint + typecheck + unit tests GREEN
4. CI green — pipeline passes after merge
5. Issue closed — linked issue closed in Gitea
6. Docs updated — API/auth/schema changes require doc update
Workers NEVER merge PRs. Ever. Open PR → fire system event → EXIT.`);
return sections.join('\n\n---\n\n');
}
// ---------------------------------------------------------------------------
// Build mission context block (dynamic — injected as prependContext)
// ---------------------------------------------------------------------------
function buildMissionContext(mission: ActiveMission): string {
const tasksFile = path.join(mission.projectRoot, 'docs/TASKS.md');
const tasksContent = safeRead(tasksFile);
// Extract just the next not-started task to keep context compact
let nextTask = '';
if (tasksContent) {
const notStartedMatch = tasksContent.match(
/\|[^|]*\|\s*not[-\s]?started[^|]*\|[^|]*\|[^|]*\|/i,
);
if (notStartedMatch) {
nextTask = `\n**Next task:** ${notStartedMatch[0].replace(/\|/g, ' ').trim()}`;
}
}
return `[Mosaic Framework] Active mission: **${mission.name}** (${mission.id})
Status: ${mission.status} | Milestones: ${mission.milestonesCompleted}/${mission.milestonesTotal}
Project: ${mission.projectRoot}${nextTask}
Read ORCHESTRATOR-PROTOCOL.md + TASKS.md before proceeding.`;
}
function buildMacpContext(projectRoot: string): string | null {
const orchDir = path.join(projectRoot, '.mosaic/orchestrator');
const configPath = path.join(orchDir, 'config.json');
if (!existsSync(configPath)) return null;
const config = safeReadJson(configPath);
if (!config?.enabled) return null;
const tasksPath = path.join(orchDir, 'tasks.json');
const tasksPayload = safeReadJson(tasksPath);
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
const counts = {
pending: 0,
running: 0,
completed: 0,
failed: 0,
escalated: 0,
};
for (const task of tasks) {
if (typeof task !== 'object' || task === null) continue;
const status = String((task as Record<string, unknown>).status ?? 'pending');
if (status in counts) {
counts[status as keyof typeof counts] += 1;
}
}
const lines = [
'[MACP Queue]',
`Queue: pending=${counts.pending} running=${counts.running} completed=${counts.completed} failed=${counts.failed} escalated=${counts.escalated}`,
];
const events = safeReadNdjson(path.join(orchDir, 'events.ndjson'));
if (events.length > 0) {
lines.push('Recent activity:');
for (const event of events) {
const timestamp = String(event.timestamp ?? '?');
const eventType = String(event.event_type ?? 'event');
const taskId = String(event.task_id ?? '-');
const message = String(event.message ?? '').trim();
lines.push(`- ${timestamp} | ${eventType} | ${taskId}${message ? ` | ${message}` : ''}`);
}
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Write runtime contract to ACP worker config files
// ---------------------------------------------------------------------------
function writeCodexInstructions(mosaicHome: string, mission: ActiveMission | null): void {
const contract = buildRuntimeContract(mosaicHome, mission, mission?.projectRoot);
const dest = path.join(os.homedir(), '.codex/instructions.md');
mkdirSync(path.dirname(dest), { recursive: true });
writeFileSync(dest, contract, 'utf8');
}
function writeClaudeInstructions(mosaicHome: string, mission: ActiveMission | null): void {
// Claude Code reads from ~/.claude/CLAUDE.md
const contract = buildRuntimeContract(mosaicHome, mission, mission?.projectRoot);
const dest = path.join(os.homedir(), '.claude/CLAUDE.md');
mkdirSync(path.dirname(dest), { recursive: true });
// Only write if different to avoid unnecessary disk writes
const existing = safeRead(dest);
if (existing !== contract) {
writeFileSync(dest, contract, 'utf8');
}
}
// ---------------------------------------------------------------------------
// Build static framework preamble for OC native agents (appendSystemContext)
// ---------------------------------------------------------------------------
function buildFrameworkPreamble(mosaicHome: string): string {
const agentsFile = path.join(mosaicHome, 'AGENTS.md');
const agentsContent = safeRead(agentsFile);
const lines: string[] = [
'# Mosaic Framework Contract (Auto-injected)',
'',
'You are operating under the Mosaic multi-agent framework.',
'The following rules are MANDATORY and OVERRIDE any conflicting defaults.',
'',
];
if (agentsContent) {
// Extract hard rules section
const hardRulesMatch = agentsContent.match(/## ⛔ HARD RULES[\s\S]*?(?=^## [^⛔]|\z)/m);
if (hardRulesMatch) {
lines.push('## Hard Rules (Compaction-Resistant)\n');
lines.push(hardRulesMatch[0].trim());
}
}
lines.push(
'',
'## Completion Gates',
'A task is NOT done until: code review ✓ | security review ✓ | tests GREEN ✓ | CI green ✓ | issue closed ✓ | docs updated ✓',
'',
'## Worker Completion Protocol',
'Workers NEVER merge PRs. Implement → lint/typecheck → push branch → open PR → fire system event → EXIT.',
'',
'## Worktree Requirement',
'All code work MUST use a git worktree at `~/src/<repo>-worktrees/<task-slug>`. Never use /tmp.',
);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Plugin registration
// ---------------------------------------------------------------------------
export default function register(api: OpenClawPluginApi) {
const cfg = (api.config ?? {}) as MosaicFrameworkConfig;
const mosaicHome = expandHome(cfg.mosaicHome ?? '~/.config/mosaic');
const projectRoots = (cfg.projectRoots ?? []).map(expandHome);
const requireMission = cfg.requireMission ?? false;
const injectAgentIds = cfg.injectAgentIds ?? null; // null = all agents
const acpAgentIds = new Set(cfg.acpAgentIds ?? ['codex', 'claude']);
// Pre-build the static framework preamble (injected once per session start)
const frameworkPreamble = buildFrameworkPreamble(mosaicHome);
// ---------------------------------------------------------------------------
// Hook 1: before_agent_start — inject into OC native agent sessions
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api.on('before_agent_start', async (_event: any, ctx: any) => {
const agentId = ctx.agentId ?? 'unknown';
// Skip if this agent is not in the inject list (when configured)
if (injectAgentIds !== null && !injectAgentIds.includes(agentId)) {
return {};
}
// Skip ACP worker sessions — they get injected via subagent_spawning instead
if (acpAgentIds.has(agentId)) {
return {};
}
// Read active mission for this turn (dynamic)
const mission = projectRoots.length > 0 ? findActiveMission(projectRoots) : null;
const result: Record<string, string> = {};
// Static framework preamble → appendSystemContext (cached by provider)
result.appendSystemContext = frameworkPreamble;
// Dynamic mission/MACP state → prependContext (fresh each turn)
const sections: string[] = [];
if (mission) {
sections.push(buildMissionContext(mission));
}
const macpProjectRoot = mission?.projectRoot ?? findMacpProjectRoot(projectRoots);
if (macpProjectRoot) {
const macpContext = buildMacpContext(macpProjectRoot);
if (macpContext) {
sections.push(macpContext);
}
}
if (sections.length > 0) {
result.prependContext = sections.join('\n\n');
}
return result;
});
// ---------------------------------------------------------------------------
// Hook 2: subagent_spawning — inject runtime contract into ACP workers
//
// Mission context is intentionally NOT injected here. The runtime contract
// includes instructions to read .mosaic/orchestrator/mission.json from the
// worker's own CWD — so the worker picks up the correct project mission
// itself. Injecting a mission here would risk cross-contamination when
// multiple projects have active missions simultaneously.
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api.on('subagent_spawning', async (event: any, _ctx: any) => {
const childAgentId = (event as Record<string, unknown>).agentId as string | undefined;
if (!childAgentId) return { status: 'ok' };
// Only act on ACP coding worker spawns
if (!acpAgentIds.has(childAgentId)) {
return { status: 'ok' };
}
// Gate: block spawn if requireMission is true and no active mission found in any root
if (requireMission) {
const mission = projectRoots.length > 0 ? findActiveMission(projectRoots) : null;
if (!mission) {
return {
status: 'error',
error: `[mosaic-framework] No active Mosaic mission found. Run 'mosaic coord init' in your project directory first. Scanned: ${projectRoots.join(', ')}`,
};
}
}
// Write runtime contract (global framework rules + load order, no mission context)
// The worker will detect its own mission from .mosaic/orchestrator/mission.json in its CWD.
try {
if (childAgentId === 'codex') {
writeCodexInstructions(mosaicHome, null);
} else if (childAgentId === 'claude') {
writeClaudeInstructions(mosaicHome, null);
}
} catch (err) {
// Log but don't block — better to have a worker without full rails than no worker
api.logger?.warn(
`[mosaic-framework] Failed to write runtime contract for ${childAgentId}: ${String(err)}`,
);
}
return { status: 'ok' };
});
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
# @mosaicstack/telegram-plugin
`@mosaicstack/telegram-plugin` connects a Telegram bot to the Mosaic gateway chat namespace so Telegram chats can participate in the same conversation flow as the web, TUI, and Discord channels.
## Required Environment Variables
- `TELEGRAM_BOT_TOKEN`: Bot token issued by BotFather
- `TELEGRAM_GATEWAY_URL`: Base URL for the Mosaic gateway, for example `http://localhost:3000`
## What It Does
- Launches a Telegram bot with `telegraf`
- Connects to `${TELEGRAM_GATEWAY_URL}/chat` with `socket.io-client`
- Maps Telegram `chat.id` values to Mosaic `conversationId` values
- Forwards inbound Telegram text messages to the gateway as user messages
- Buffers `agent:start` / `agent:text` / `agent:end` socket events and sends the completed response back to the Telegram chat
## Getting a Bot Token
1. Open Telegram and start a chat with `@BotFather`
2. Run `/newbot`
3. Follow the prompts to name the bot and choose a username
4. Copy the generated token and assign it to `TELEGRAM_BOT_TOKEN`
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@mosaicstack/telegram-plugin",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/telegram"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"dependencies": {
"socket.io-client": "^4.8.0",
"telegraf": "^4.16.3"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+187
View File
@@ -0,0 +1,187 @@
import { Telegraf } from 'telegraf';
import { io, type Socket } from 'socket.io-client';
interface TelegramPluginConfig {
token: string;
gatewayUrl: string;
}
interface TelegramUser {
is_bot?: boolean;
}
interface TelegramChat {
id: number;
}
interface TelegramTextMessage {
chat: TelegramChat;
from?: TelegramUser;
text: string;
}
class TelegramPlugin {
readonly name = 'telegram';
private bot: Telegraf;
private socket: Socket | null = null;
private config: TelegramPluginConfig;
/** Map Telegram chat ID → Mosaic conversation ID */
private chatConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming */
private pendingResponses = new Map<string, string>();
constructor(config: TelegramPluginConfig) {
this.config = config;
this.bot = new Telegraf(this.config.token);
}
async start(): Promise<void> {
// Connect to gateway WebSocket
this.socket = io(`${this.config.gatewayUrl}/chat`, {
transports: ['websocket'],
});
this.socket.on('connect', () => {
console.log('[telegram] Connected to gateway');
});
this.socket.on('disconnect', (reason: string) => {
console.error(`[telegram] Disconnected from gateway: ${reason}`);
this.pendingResponses.clear();
});
this.socket.on('connect_error', (err: Error) => {
console.error(`[telegram] Gateway connection error: ${err.message}`);
});
// Handle streaming text from gateway
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) {
this.pendingResponses.set(data.conversationId, pending + data.text);
}
});
// When agent finishes, send the accumulated response
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToTelegram(data.conversationId, text).catch((err) => {
console.error(`[telegram] Error sending response for ${data.conversationId}:`, err);
});
}
});
this.socket.on('agent:start', (data: { conversationId: string }) => {
this.pendingResponses.set(data.conversationId, '');
});
// Set up Telegram message handler
this.bot.on('message', (ctx) => {
const message = this.getTextMessage(ctx.message);
if (message) {
this.handleTelegramMessage(message);
}
});
await this.bot.launch();
}
async stop(): Promise<void> {
this.bot.stop('SIGTERM');
this.socket?.disconnect();
}
private handleTelegramMessage(message: TelegramTextMessage): void {
// Ignore bot messages
if (message.from?.is_bot) return;
const content = message.text.trim();
if (!content) return;
// Get or create conversation for this Telegram chat
const chatId = String(message.chat.id);
let conversationId = this.chatConversations.get(chatId);
if (!conversationId) {
conversationId = `telegram-${chatId}`;
this.chatConversations.set(chatId, conversationId);
}
// Send to gateway
if (!this.socket?.connected) {
console.error(`[telegram] Cannot forward message: not connected to gateway. chat=${chatId}`);
return;
}
this.socket.emit('message', {
conversationId,
content,
role: 'user',
});
}
private getTextMessage(message: unknown): TelegramTextMessage | null {
if (!message || typeof message !== 'object') return null;
const candidate = message as Partial<TelegramTextMessage>;
if (typeof candidate.text !== 'string') return null;
if (!candidate.chat || typeof candidate.chat.id !== 'number') return null;
return {
chat: candidate.chat,
from: candidate.from,
text: candidate.text,
};
}
private async sendToTelegram(conversationId: string, text: string): Promise<void> {
// Find the Telegram chat for this conversation
const chatId = Array.from(this.chatConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
if (!chatId) {
console.error(`[telegram] No chat found for conversation ${conversationId}`);
return;
}
// Chunk responses for Telegram's 4096-char limit
const chunks = this.chunkText(text, 4000);
for (const chunk of chunks) {
try {
await this.bot.telegram.sendMessage(chatId, chunk);
} catch (err) {
console.error(`[telegram] Failed to send message to chat ${chatId}:`, err);
}
}
}
private chunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to break at a newline
let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart();
}
return chunks;
}
}
export { TelegramPlugin };
export type { TelegramPluginConfig };
export const VERSION = '0.0.5';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});