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.
- package/dist/cli/auto-discover.d.ts +11 -0
- package/dist/cli/auto-discover.js +60 -0
- package/dist/cli/discord-api.d.ts +19 -0
- package/dist/cli/discord-api.js +53 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +313 -0
- package/dist/cli/prompts.d.ts +17 -0
- package/dist/cli/prompts.js +178 -0
- package/dist/cli/systemd.d.ts +2 -0
- package/dist/cli/systemd.js +22 -0
- package/dist/cli/templates.d.ts +7 -0
- package/dist/cli/templates.js +17 -0
- package/dist/plugin/discord-client.d.ts +13 -0
- package/dist/plugin/discord-client.js +43 -0
- package/dist/plugin/index.d.ts +2 -0
- package/dist/plugin/index.js +50 -0
- package/dist/plugin/mcp-server.d.ts +39 -0
- package/dist/plugin/mcp-server.js +60 -0
- package/dist/plugin/message-batcher.d.ts +9 -0
- package/dist/plugin/message-batcher.js +29 -0
- package/package.json +36 -0
- package/scripts/check-worker.sh +19 -0
- package/scripts/dissolve-worker.sh +77 -0
- package/scripts/healthcheck.sh +30 -0
- package/scripts/list-workers.sh +11 -0
- package/scripts/spawn-worker.sh +253 -0
- package/scripts/start-orchestrator.sh +32 -0
- package/src/plugin/discord-client.ts +68 -0
- package/src/plugin/index.ts +60 -0
- package/src/plugin/mcp-server.ts +79 -0
- package/src/plugin/message-batcher.ts +33 -0
- package/templates/orchestrator-claude.md.hbs +95 -0
- package/templates/settings.json.hbs +20 -0
|
@@ -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,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,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,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"
|