onkol 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ export function generateSystemdUnit(nodeName, user, onkolDir) {
2
+ return `[Unit]
3
+ Description=Onkol Node: ${nodeName}
4
+ After=network.target
5
+
6
+ [Service]
7
+ Type=forking
8
+ User=${user}
9
+ ExecStart=${onkolDir}/scripts/start-orchestrator.sh
10
+ ExecStop=/usr/bin/tmux kill-session -t onkol-${nodeName}
11
+ Restart=on-failure
12
+ RestartSec=10
13
+
14
+ [Install]
15
+ WantedBy=multi-user.target
16
+ `;
17
+ }
18
+ export function generateCrontab(onkolDir) {
19
+ return `*/5 * * * * ${onkolDir}/scripts/healthcheck.sh
20
+ 0 4 * * * find ${onkolDir}/workers/.archive -maxdepth 1 -mtime +30 -exec rm -rf {} \\;
21
+ `;
22
+ }
@@ -0,0 +1,7 @@
1
+ export declare function renderOrchestratorClaude(data: {
2
+ nodeName: string;
3
+ maxWorkers: number;
4
+ }): string;
5
+ export declare function renderSettings(data: {
6
+ bashLogPath: string;
7
+ }): string;
@@ -0,0 +1,17 @@
1
+ import Handlebars from 'handlebars';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATES_DIR = resolve(__dirname, '../../templates');
7
+ function loadTemplate(name) {
8
+ const content = readFileSync(resolve(TEMPLATES_DIR, name), 'utf-8');
9
+ return Handlebars.compile(content);
10
+ }
11
+ Handlebars.registerHelper('eq', (a, b) => a === b);
12
+ export function renderOrchestratorClaude(data) {
13
+ return loadTemplate('orchestrator-claude.md.hbs')(data);
14
+ }
15
+ export function renderSettings(data) {
16
+ return loadTemplate('settings.json.hbs')(data);
17
+ }
@@ -0,0 +1,13 @@
1
+ import { Client, type Message } from 'discord.js';
2
+ export interface DiscordClientConfig {
3
+ botToken: string;
4
+ channelId: string;
5
+ allowedUsers: string[];
6
+ }
7
+ export declare function shouldForwardMessage(messageChannelId: string, authorId: string, isBot: boolean, targetChannelId: string, allowedUsers: string[]): boolean;
8
+ export declare function createDiscordClient(config: DiscordClientConfig, onMessage: (message: Message) => void): {
9
+ login: () => Promise<string>;
10
+ client: Client<boolean>;
11
+ sendMessage(channelId: string, text: string): Promise<void>;
12
+ sendMessageWithFile(channelId: string, text: string, filePath: string): Promise<void>;
13
+ };
@@ -0,0 +1,43 @@
1
+ import { Client, GatewayIntentBits } from 'discord.js';
2
+ export function shouldForwardMessage(messageChannelId, authorId, isBot, targetChannelId, allowedUsers) {
3
+ if (isBot)
4
+ return false;
5
+ if (messageChannelId !== targetChannelId)
6
+ return false;
7
+ if (allowedUsers.length > 0 && !allowedUsers.includes(authorId))
8
+ return false;
9
+ return true;
10
+ }
11
+ export function createDiscordClient(config, onMessage) {
12
+ const client = new Client({
13
+ intents: [
14
+ GatewayIntentBits.Guilds,
15
+ GatewayIntentBits.GuildMessages,
16
+ GatewayIntentBits.MessageContent,
17
+ ],
18
+ });
19
+ client.on('messageCreate', (message) => {
20
+ if (shouldForwardMessage(message.channel.id, message.author.id, message.author.bot, config.channelId, config.allowedUsers)) {
21
+ onMessage(message);
22
+ }
23
+ });
24
+ client.on('ready', () => {
25
+ console.error(`[discord-filtered] Connected as ${client.user?.tag}, filtering to channel ${config.channelId}`);
26
+ });
27
+ return {
28
+ login: () => client.login(config.botToken),
29
+ client,
30
+ async sendMessage(channelId, text) {
31
+ const channel = await client.channels.fetch(channelId);
32
+ if (channel?.isTextBased() && 'send' in channel) {
33
+ await channel.send(text);
34
+ }
35
+ },
36
+ async sendMessageWithFile(channelId, text, filePath) {
37
+ const channel = await client.channels.fetch(channelId);
38
+ if (channel?.isTextBased() && 'send' in channel) {
39
+ await channel.send({ content: text, files: [{ attachment: filePath }] });
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createMcpServer } from './mcp-server.js';
4
+ import { createDiscordClient } from './discord-client.js';
5
+ import { MessageBatcher } from './message-batcher.js';
6
+ const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
7
+ const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
8
+ const ALLOWED_USERS = JSON.parse(process.env.DISCORD_ALLOWED_USERS || '[]');
9
+ if (!BOT_TOKEN) {
10
+ console.error('[discord-filtered] DISCORD_BOT_TOKEN is required');
11
+ process.exit(1);
12
+ }
13
+ if (!CHANNEL_ID) {
14
+ console.error('[discord-filtered] DISCORD_CHANNEL_ID is required');
15
+ process.exit(1);
16
+ }
17
+ const discord = createDiscordClient({ botToken: BOT_TOKEN, channelId: CHANNEL_ID, allowedUsers: ALLOWED_USERS }, async (message) => {
18
+ await mcpServer.notification({
19
+ method: 'notifications/claude/channel',
20
+ params: {
21
+ content: message.content,
22
+ meta: {
23
+ channel_id: message.channel.id,
24
+ sender: message.author.username,
25
+ sender_id: message.author.id,
26
+ message_id: message.id,
27
+ },
28
+ },
29
+ });
30
+ });
31
+ const batcher = new MessageBatcher(async (text) => {
32
+ await discord.sendMessage(CHANNEL_ID, text);
33
+ });
34
+ const mcpServer = createMcpServer({
35
+ async reply(_channelId, text) {
36
+ batcher.enqueue(text);
37
+ },
38
+ async replyWithFile(_channelId, text, filePath) {
39
+ await discord.sendMessageWithFile(CHANNEL_ID, text, filePath);
40
+ },
41
+ });
42
+ async function main() {
43
+ await mcpServer.connect(new StdioServerTransport());
44
+ await discord.login();
45
+ console.error(`[discord-filtered] Ready. Listening to channel ${CHANNEL_ID}`);
46
+ }
47
+ main().catch((err) => {
48
+ console.error('[discord-filtered] Fatal error:', err);
49
+ process.exit(1);
50
+ });
@@ -0,0 +1,39 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ export interface McpToolHandlers {
3
+ reply: (channelId: string, text: string) => Promise<void>;
4
+ replyWithFile: (channelId: string, text: string, filePath: string) => Promise<void>;
5
+ }
6
+ export declare function createMcpServer(handlers?: McpToolHandlers): Server<{
7
+ method: string;
8
+ params?: {
9
+ [x: string]: unknown;
10
+ _meta?: {
11
+ [x: string]: unknown;
12
+ progressToken?: string | number | undefined;
13
+ "io.modelcontextprotocol/related-task"?: {
14
+ taskId: string;
15
+ } | undefined;
16
+ } | undefined;
17
+ } | undefined;
18
+ }, {
19
+ method: string;
20
+ params?: {
21
+ [x: string]: unknown;
22
+ _meta?: {
23
+ [x: string]: unknown;
24
+ progressToken?: string | number | undefined;
25
+ "io.modelcontextprotocol/related-task"?: {
26
+ taskId: string;
27
+ } | undefined;
28
+ } | undefined;
29
+ } | undefined;
30
+ }, {
31
+ [x: string]: unknown;
32
+ _meta?: {
33
+ [x: string]: unknown;
34
+ progressToken?: string | number | undefined;
35
+ "io.modelcontextprotocol/related-task"?: {
36
+ taskId: string;
37
+ } | undefined;
38
+ } | undefined;
39
+ }>;
@@ -0,0 +1,60 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ export function createMcpServer(handlers) {
4
+ const server = new Server({ name: 'discord-filtered', version: '0.1.0' }, {
5
+ capabilities: {
6
+ experimental: { 'claude/channel': {} },
7
+ tools: {},
8
+ },
9
+ instructions: 'Messages arrive as <channel source="discord-filtered">. Reply using the reply tool. Use reply_with_file to attach files.',
10
+ });
11
+ const channelId = process.env.DISCORD_CHANNEL_ID || '';
12
+ const tools = [
13
+ {
14
+ name: 'reply',
15
+ description: 'Send a text message back to the Discord channel',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ text: { type: 'string', description: 'The message text to send' },
20
+ },
21
+ required: ['text'],
22
+ },
23
+ },
24
+ {
25
+ name: 'reply_with_file',
26
+ description: 'Send a text message with a file attachment to the Discord channel',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ text: { type: 'string', description: 'The message text to send' },
31
+ file_path: { type: 'string', description: 'Absolute path to the file to attach' },
32
+ },
33
+ required: ['text', 'file_path'],
34
+ },
35
+ },
36
+ ];
37
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
38
+ tools,
39
+ }));
40
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
41
+ const { name, arguments: args } = req.params;
42
+ if (name === 'reply' && handlers) {
43
+ await handlers.reply(channelId, args.text);
44
+ return { content: [{ type: 'text', text: 'sent' }] };
45
+ }
46
+ if (name === 'reply_with_file' && handlers) {
47
+ await handlers.replyWithFile(channelId, args.text, args.file_path);
48
+ return { content: [{ type: 'text', text: 'sent' }] };
49
+ }
50
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }] };
51
+ });
52
+ server.listTools = async () => {
53
+ const handler = server._requestHandlers.get('tools/list');
54
+ if (!handler)
55
+ return [];
56
+ const result = await handler({ method: 'tools/list', params: {} }, {});
57
+ return result?.tools || [];
58
+ };
59
+ return server;
60
+ }
@@ -0,0 +1,9 @@
1
+ export declare class MessageBatcher {
2
+ private buffer;
3
+ private timer;
4
+ private sendFn;
5
+ private delayMs;
6
+ constructor(sendFn: (text: string) => Promise<void>, delayMs?: number);
7
+ enqueue(text: string): void;
8
+ private flush;
9
+ }
@@ -0,0 +1,29 @@
1
+ const DISCORD_MAX_LENGTH = 2000;
2
+ const TRUNCATION_SUFFIX = '\n... (truncated)';
3
+ export class MessageBatcher {
4
+ buffer = [];
5
+ timer = null;
6
+ sendFn;
7
+ delayMs;
8
+ constructor(sendFn, delayMs = 3000) {
9
+ this.sendFn = sendFn;
10
+ this.delayMs = delayMs;
11
+ }
12
+ enqueue(text) {
13
+ this.buffer.push(text);
14
+ if (this.timer)
15
+ clearTimeout(this.timer);
16
+ this.timer = setTimeout(() => this.flush(), this.delayMs);
17
+ }
18
+ async flush() {
19
+ if (this.buffer.length === 0)
20
+ return;
21
+ let combined = this.buffer.join('\n');
22
+ this.buffer = [];
23
+ this.timer = null;
24
+ if (combined.length > DISCORD_MAX_LENGTH) {
25
+ combined = combined.slice(0, DISCORD_MAX_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
26
+ }
27
+ await this.sendFn(combined);
28
+ }
29
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "onkol",
3
+ "version": "0.1.0",
4
+ "description": "Decentralized on-call agent system powered by Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "onkol": "dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "scripts/",
12
+ "templates/",
13
+ "src/plugin/"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "bun test",
18
+ "dev:plugin": "bun run src/plugin/index.ts"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0",
22
+ "discord.js": "^14.0.0",
23
+ "handlebars": "^4.7.0",
24
+ "inquirer": "^12.0.0",
25
+ "chalk": "^5.0.0",
26
+ "commander": "^13.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.7.0",
31
+ "bun-types": "^1.2.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ #!/bin/bash
2
+ while [[ $# -gt 0 ]]; do
3
+ case $1 in
4
+ --name) WORKER_NAME="$2"; shift 2 ;;
5
+ *) echo "Unknown arg: $1"; exit 1 ;;
6
+ esac
7
+ done
8
+
9
+ : "${WORKER_NAME:?--name is required}"
10
+
11
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
12
+ STATUS_FILE="$ONKOL_DIR/workers/$WORKER_NAME/status.json"
13
+
14
+ if [ ! -f "$STATUS_FILE" ]; then
15
+ echo "Worker '$WORKER_NAME' not found or has no status file."
16
+ exit 1
17
+ fi
18
+
19
+ jq '.' "$STATUS_FILE"
@@ -0,0 +1,77 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ while [[ $# -gt 0 ]]; do
5
+ case $1 in
6
+ --name) WORKER_NAME="$2"; shift 2 ;;
7
+ *) echo "Unknown arg: $1"; exit 1 ;;
8
+ esac
9
+ done
10
+
11
+ : "${WORKER_NAME:?--name is required}"
12
+
13
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
14
+ CONFIG="$ONKOL_DIR/config.json"
15
+ BOT_TOKEN=$(jq -r '.botToken' "$CONFIG")
16
+ NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
17
+ TMUX_SESSION="onkol-${NODE_NAME}"
18
+ WORKER_DIR="$ONKOL_DIR/workers/$WORKER_NAME"
19
+ TRACKING="$ONKOL_DIR/workers/tracking.json"
20
+
21
+ # Get channel ID from tracking
22
+ CHANNEL_ID=$(jq -r ".[] | select(.name == \"$WORKER_NAME\") | .channelId" "$TRACKING")
23
+
24
+ # Check learnings file
25
+ if [ ! -s "$WORKER_DIR/learnings.md" ]; then
26
+ echo "WARNING: No learnings found at $WORKER_DIR/learnings.md"
27
+ echo "Worker should write learnings before dissolution."
28
+ fi
29
+
30
+ # Copy learnings to knowledge base
31
+ DATE=$(date +%Y-%m-%d)
32
+ KNOWLEDGE_DIR="$ONKOL_DIR/knowledge"
33
+ mkdir -p "$KNOWLEDGE_DIR"
34
+
35
+ if [ -s "$WORKER_DIR/learnings.md" ]; then
36
+ cp "$WORKER_DIR/learnings.md" "$KNOWLEDGE_DIR/${DATE}-${WORKER_NAME}.md"
37
+
38
+ # Update index.json
39
+ INDEX="$KNOWLEDGE_DIR/index.json"
40
+ if [ ! -f "$INDEX" ]; then
41
+ echo '[]' > "$INDEX"
42
+ fi
43
+ TASK_DESC=$(jq -r ".[] | select(.name == \"$WORKER_NAME\") | .intent" "$TRACKING" 2>/dev/null || echo "unknown")
44
+ WORK_DIR=$(jq -r ".[] | select(.name == \"$WORKER_NAME\") | .workDir" "$TRACKING" 2>/dev/null || echo "unknown")
45
+ UPDATED_INDEX=$(jq ". + [{
46
+ \"file\": \"${DATE}-${WORKER_NAME}.md\",
47
+ \"date\": \"$DATE\",
48
+ \"tags\": [],
49
+ \"project\": \"$WORK_DIR\",
50
+ \"summary\": \"Learnings from worker $WORKER_NAME ($TASK_DESC)\"
51
+ }]" "$INDEX")
52
+ echo "$UPDATED_INDEX" > "$INDEX"
53
+ echo "Learnings saved to $KNOWLEDGE_DIR/${DATE}-${WORKER_NAME}.md"
54
+ fi
55
+
56
+ # Kill tmux window (if exists)
57
+ tmux kill-window -t "${TMUX_SESSION}:${WORKER_NAME}" 2>/dev/null || true
58
+
59
+ # Delete Discord channel (if exists)
60
+ if [ -n "$CHANNEL_ID" ] && [ "$CHANNEL_ID" != "null" ]; then
61
+ curl -s -X DELETE \
62
+ "https://discord.com/api/v10/channels/${CHANNEL_ID}" \
63
+ -H "Authorization: Bot ${BOT_TOKEN}" > /dev/null 2>&1 || true
64
+ echo "Discord channel deleted."
65
+ fi
66
+
67
+ # Archive worker directory
68
+ ARCHIVE_DIR="$ONKOL_DIR/workers/.archive/${DATE}-${WORKER_NAME}"
69
+ mkdir -p "$ONKOL_DIR/workers/.archive"
70
+ mv "$WORKER_DIR" "$ARCHIVE_DIR"
71
+ echo "Worker directory archived to $ARCHIVE_DIR"
72
+
73
+ # Remove from tracking
74
+ UPDATED=$(jq "[.[] | select(.name != \"$WORKER_NAME\")]" "$TRACKING")
75
+ echo "$UPDATED" > "$TRACKING"
76
+
77
+ echo "Worker '$WORKER_NAME' dissolved."
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # Cron-based health check. Compares tracking.json against tmux windows.
3
+ # If a worker is tracked but its tmux window is gone, sends alert to Discord.
4
+
5
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
6
+ CONFIG="$ONKOL_DIR/config.json"
7
+ TRACKING="$ONKOL_DIR/workers/tracking.json"
8
+
9
+ if [ ! -f "$TRACKING" ] || [ "$(jq length "$TRACKING")" -eq 0 ]; then
10
+ exit 0
11
+ fi
12
+
13
+ BOT_TOKEN=$(jq -r '.botToken' "$CONFIG")
14
+ ORCHESTRATOR_CHANNEL=$(jq -r '.orchestratorChannelId' "$CONFIG")
15
+ NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
16
+ TMUX_SESSION="onkol-${NODE_NAME}"
17
+
18
+ WINDOWS=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_name}' 2>/dev/null || echo "")
19
+
20
+ jq -r '.[] | select(.status == "active") | .name' "$TRACKING" | while read -r WORKER; do
21
+ if ! echo "$WINDOWS" | grep -q "^${WORKER}$"; then
22
+ # Worker is tracked but tmux window is gone
23
+ curl -s -X POST \
24
+ "https://discord.com/api/v10/channels/${ORCHESTRATOR_CHANNEL}/messages" \
25
+ -H "Authorization: Bot ${BOT_TOKEN}" \
26
+ -H "Content-Type: application/json" \
27
+ -d "{\"content\": \"[healthcheck] Worker **${WORKER}** appears to have crashed. Its tmux window is gone but it's still tracked. Please check and decide: respawn or dissolve.\"}" \
28
+ > /dev/null 2>&1
29
+ fi
30
+ done
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
3
+ TRACKING="$ONKOL_DIR/workers/tracking.json"
4
+
5
+ if [ ! -f "$TRACKING" ] || [ "$(jq length "$TRACKING")" -eq 0 ]; then
6
+ echo "No active workers."
7
+ exit 0
8
+ fi
9
+
10
+ echo "Active workers:"
11
+ jq -r '.[] | " [\(.status)] \(.name) — intent: \(.intent), dir: \(.workDir), started: \(.started)"' "$TRACKING"