chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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' };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user