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,253 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Parse arguments
|
|
5
|
+
while [[ $# -gt 0 ]]; do
|
|
6
|
+
case $1 in
|
|
7
|
+
--name) WORKER_NAME="$2"; shift 2 ;;
|
|
8
|
+
--dir) WORK_DIR="$2"; shift 2 ;;
|
|
9
|
+
--task) TASK_DESC="$2"; shift 2 ;;
|
|
10
|
+
--intent) INTENT="$2"; shift 2 ;;
|
|
11
|
+
--context) CONTEXT="$2"; shift 2 ;;
|
|
12
|
+
*) echo "Unknown arg: $1"; exit 1 ;;
|
|
13
|
+
esac
|
|
14
|
+
done
|
|
15
|
+
|
|
16
|
+
# Validate required args
|
|
17
|
+
: "${WORKER_NAME:?--name is required}"
|
|
18
|
+
: "${WORK_DIR:?--dir is required}"
|
|
19
|
+
: "${TASK_DESC:?--task is required}"
|
|
20
|
+
: "${INTENT:=fix}"
|
|
21
|
+
: "${CONTEXT:=No additional context.}"
|
|
22
|
+
|
|
23
|
+
# Load config
|
|
24
|
+
ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
25
|
+
CONFIG="$ONKOL_DIR/config.json"
|
|
26
|
+
BOT_TOKEN=$(jq -r '.botToken' "$CONFIG")
|
|
27
|
+
GUILD_ID=$(jq -r '.guildId' "$CONFIG")
|
|
28
|
+
CATEGORY_ID=$(jq -r '.categoryId' "$CONFIG")
|
|
29
|
+
ALLOWED_USERS=$(jq -c '.allowedUsers' "$CONFIG")
|
|
30
|
+
NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
|
|
31
|
+
MAX_WORKERS=$(jq -r '.maxWorkers // 3' "$CONFIG")
|
|
32
|
+
TMUX_SESSION="onkol-${NODE_NAME}"
|
|
33
|
+
|
|
34
|
+
# Check concurrency limit
|
|
35
|
+
TRACKING="$ONKOL_DIR/workers/tracking.json"
|
|
36
|
+
if [ -f "$TRACKING" ]; then
|
|
37
|
+
ACTIVE_COUNT=$(jq '[.[] | select(.status == "active")] | length' "$TRACKING")
|
|
38
|
+
if [ "$ACTIVE_COUNT" -ge "$MAX_WORKERS" ]; then
|
|
39
|
+
echo "ERROR: Worker limit reached ($ACTIVE_COUNT/$MAX_WORKERS). Task queued."
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Create Discord channel
|
|
45
|
+
CHANNEL_RESPONSE=$(curl -s -X POST \
|
|
46
|
+
"https://discord.com/api/v10/guilds/${GUILD_ID}/channels" \
|
|
47
|
+
-H "Authorization: Bot ${BOT_TOKEN}" \
|
|
48
|
+
-H "Content-Type: application/json" \
|
|
49
|
+
-d "{\"name\": \"$(echo "$WORKER_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')\", \"type\": 0, \"parent_id\": \"${CATEGORY_ID}\"}")
|
|
50
|
+
|
|
51
|
+
CHANNEL_ID=$(echo "$CHANNEL_RESPONSE" | jq -r '.id')
|
|
52
|
+
if [ "$CHANNEL_ID" = "null" ] || [ -z "$CHANNEL_ID" ]; then
|
|
53
|
+
echo "ERROR: Failed to create Discord channel: $CHANNEL_RESPONSE"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Create worker directory
|
|
58
|
+
WORKER_DIR="$ONKOL_DIR/workers/$WORKER_NAME"
|
|
59
|
+
mkdir -p "$WORKER_DIR"
|
|
60
|
+
|
|
61
|
+
# Write task.md (using printf to prevent heredoc injection from user input)
|
|
62
|
+
printf '%s\n' "# Task: $WORKER_NAME" "" \
|
|
63
|
+
"**Intent:** $INTENT" \
|
|
64
|
+
"**Working directory:** $WORK_DIR" \
|
|
65
|
+
"**Created:** $(date -Iseconds)" "" \
|
|
66
|
+
"## Description" "" > "$WORKER_DIR/task.md"
|
|
67
|
+
printf '%s' "$TASK_DESC" >> "$WORKER_DIR/task.md"
|
|
68
|
+
|
|
69
|
+
# Write context.md (using printf to prevent heredoc injection from user input)
|
|
70
|
+
printf '%s\n' "# Context for $WORKER_NAME" "" > "$WORKER_DIR/context.md"
|
|
71
|
+
printf '%s' "$CONTEXT" >> "$WORKER_DIR/context.md"
|
|
72
|
+
|
|
73
|
+
# Write .mcp.json (DISCORD_ALLOWED_USERS must be a string, not raw JSON array)
|
|
74
|
+
ALLOWED_USERS_ESCAPED=$(echo "$ALLOWED_USERS" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
75
|
+
PLUGIN_PATH="$ONKOL_DIR/plugins/discord-filtered/index.ts"
|
|
76
|
+
cat > "$WORKER_DIR/.mcp.json" << MCPEOF
|
|
77
|
+
{
|
|
78
|
+
"mcpServers": {
|
|
79
|
+
"discord-filtered": {
|
|
80
|
+
"command": "bun",
|
|
81
|
+
"args": ["$PLUGIN_PATH"],
|
|
82
|
+
"env": {
|
|
83
|
+
"DISCORD_BOT_TOKEN": "$BOT_TOKEN",
|
|
84
|
+
"DISCORD_CHANNEL_ID": "$CHANNEL_ID",
|
|
85
|
+
"DISCORD_ALLOWED_USERS": "$ALLOWED_USERS_ESCAPED"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
MCPEOF
|
|
91
|
+
|
|
92
|
+
# Write worker CLAUDE.md
|
|
93
|
+
INTENT_INSTRUCTION=$(case $INTENT in
|
|
94
|
+
fix) echo "- Diagnose the issue, fix it, run tests, commit to a branch (not main), report results" ;;
|
|
95
|
+
investigate) echo "- Analyze the issue, gather data, report findings. Do NOT modify any files." ;;
|
|
96
|
+
build) echo "- Implement the feature, write tests, create a branch, show diff, wait for approval" ;;
|
|
97
|
+
analyze) echo "- Read logs/data/code, produce analysis, report. Do NOT modify any files." ;;
|
|
98
|
+
override) echo "- Full autonomy including push and deploy. Before deploying: ask 'About to deploy. Confirm?' and wait." ;;
|
|
99
|
+
esac)
|
|
100
|
+
|
|
101
|
+
cat > "$WORKER_DIR/CLAUDE.md" << CLEOF
|
|
102
|
+
You are an Onkol worker session for "$NODE_NAME".
|
|
103
|
+
|
|
104
|
+
## Your Task
|
|
105
|
+
Read your task brief: $WORKER_DIR/task.md
|
|
106
|
+
Read your context: $WORKER_DIR/context.md
|
|
107
|
+
|
|
108
|
+
## Intent: $INTENT
|
|
109
|
+
$INTENT_INSTRUCTION
|
|
110
|
+
|
|
111
|
+
## CRITICAL: How to Communicate
|
|
112
|
+
You are connected to Discord via the discord-filtered MCP channel.
|
|
113
|
+
ALL your output must go through the reply tool — the user CANNOT see your terminal.
|
|
114
|
+
|
|
115
|
+
- Use the \`reply\` tool from the discord-filtered MCP server to send ALL messages to the user.
|
|
116
|
+
- NEVER just print output to the terminal. The user only sees Discord.
|
|
117
|
+
- Send progress updates via reply tool as you work.
|
|
118
|
+
- Send your final report/results via reply tool.
|
|
119
|
+
- If you need to ask a question, use the reply tool. The user will respond via Discord.
|
|
120
|
+
- For long reports, split into multiple reply calls (Discord has a 2000 char limit per message).
|
|
121
|
+
|
|
122
|
+
## Rules
|
|
123
|
+
- If you get stuck, ask via the reply tool. A human will respond via Discord.
|
|
124
|
+
- Update your status in $WORKER_DIR/status.json periodically
|
|
125
|
+
- Before dissolution, write learnings to $WORKER_DIR/learnings.md
|
|
126
|
+
CLEOF
|
|
127
|
+
|
|
128
|
+
# Write initial status.json
|
|
129
|
+
cat > "$WORKER_DIR/status.json" << STATUSEOF
|
|
130
|
+
{
|
|
131
|
+
"status": "starting",
|
|
132
|
+
"updated": "$(date -Iseconds)",
|
|
133
|
+
"task": "$WORKER_NAME",
|
|
134
|
+
"intent": "$INTENT"
|
|
135
|
+
}
|
|
136
|
+
STATUSEOF
|
|
137
|
+
|
|
138
|
+
# Write per-worker .claude/settings.json with PostToolUse hook for bash logging
|
|
139
|
+
mkdir -p "$WORKER_DIR/.claude"
|
|
140
|
+
cat > "$WORKER_DIR/.claude/settings.json" << SETTINGSEOF
|
|
141
|
+
{
|
|
142
|
+
"hooks": {
|
|
143
|
+
"PostToolUse": [
|
|
144
|
+
{
|
|
145
|
+
"hooks": [
|
|
146
|
+
{
|
|
147
|
+
"type": "command",
|
|
148
|
+
"command": "jq -r 'if .tool_name == \"Bash\" then \"[\"+.tool_input.command+\"] => \"+(.tool_result.stdout // \"\" | tostring) else empty end' >> $WORKER_DIR/bash-log.txt"
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
SETTINGSEOF
|
|
156
|
+
|
|
157
|
+
# Determine allowed tools based on intent
|
|
158
|
+
case $INTENT in
|
|
159
|
+
fix|build|override) ALLOWED_TOOLS="Bash,Read,Edit,Write,Glob,Grep" ;;
|
|
160
|
+
investigate|analyze) ALLOWED_TOOLS="Bash,Read,Glob,Grep" ;;
|
|
161
|
+
*) ALLOWED_TOOLS="Bash,Read,Edit,Write,Glob,Grep" ;;
|
|
162
|
+
esac
|
|
163
|
+
|
|
164
|
+
# Pre-accept trust dialog for the working directory
|
|
165
|
+
CLAUDE_JSON="$HOME/.claude/.claude.json"
|
|
166
|
+
if [ -f "$CLAUDE_JSON" ]; then
|
|
167
|
+
UPDATED_CLAUDE=$(jq --arg dir "$WORK_DIR" '
|
|
168
|
+
.projects[$dir] = (.projects[$dir] // {}) + {hasTrustDialogAccepted: true, allowedTools: []}
|
|
169
|
+
' "$CLAUDE_JSON")
|
|
170
|
+
echo "$UPDATED_CLAUDE" > "$CLAUDE_JSON"
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# Add startup instructions to the worker CLAUDE.md so it acts immediately
|
|
174
|
+
cat >> "$WORKER_DIR/CLAUDE.md" << STARTEOF
|
|
175
|
+
|
|
176
|
+
## On Startup
|
|
177
|
+
Immediately when you start:
|
|
178
|
+
1. Read $WORKER_DIR/task.md for your task
|
|
179
|
+
2. Read $WORKER_DIR/context.md for context
|
|
180
|
+
3. Begin work according to your intent
|
|
181
|
+
4. Report progress and results using the reply tool to your Discord channel
|
|
182
|
+
Do NOT wait for a message. Start working as soon as you boot.
|
|
183
|
+
STARTEOF
|
|
184
|
+
|
|
185
|
+
# Create a self-contained wrapper script with all paths baked in
|
|
186
|
+
WRAPPER="$WORKER_DIR/start-worker.sh"
|
|
187
|
+
cat > "$WRAPPER" << WRAPEOF
|
|
188
|
+
#!/bin/bash
|
|
189
|
+
TMUX_TARGET="${TMUX_SESSION}:${WORKER_NAME}"
|
|
190
|
+
|
|
191
|
+
# Auto-accept prompts in the background
|
|
192
|
+
(
|
|
193
|
+
for i in \$(seq 1 10); do
|
|
194
|
+
sleep 2
|
|
195
|
+
PANE_CONTENT=\$(tmux capture-pane -t "\$TMUX_TARGET" -p 2>/dev/null || echo "")
|
|
196
|
+
if echo "\$PANE_CONTENT" | grep -q "^❯"; then
|
|
197
|
+
# Claude is ready — send the initial prompt via tmux keys
|
|
198
|
+
sleep 1
|
|
199
|
+
tmux send-keys -t "\$TMUX_TARGET" "Read $WORKER_DIR/task.md and $WORKER_DIR/context.md, then begin work per CLAUDE.md." Enter
|
|
200
|
+
break
|
|
201
|
+
fi
|
|
202
|
+
tmux send-keys -t "\$TMUX_TARGET" Enter 2>/dev/null || true
|
|
203
|
+
done
|
|
204
|
+
) &
|
|
205
|
+
|
|
206
|
+
# Copy .mcp.json to work directory so claude auto-discovers the MCP server.
|
|
207
|
+
# --mcp-config registers servers under a different namespace that
|
|
208
|
+
# --dangerously-load-development-channels doesn't find. The .mcp.json in cwd works.
|
|
209
|
+
# Save any existing .mcp.json and restore on exit.
|
|
210
|
+
WORK_MCP="$WORK_DIR/.mcp.json"
|
|
211
|
+
WORK_MCP_BACKUP=""
|
|
212
|
+
if [ -f "\$WORK_MCP" ]; then
|
|
213
|
+
WORK_MCP_BACKUP="\${WORK_MCP}.onkol-backup"
|
|
214
|
+
cp "\$WORK_MCP" "\$WORK_MCP_BACKUP"
|
|
215
|
+
fi
|
|
216
|
+
cp "$WORKER_DIR/.mcp.json" "\$WORK_MCP"
|
|
217
|
+
|
|
218
|
+
cleanup() {
|
|
219
|
+
if [ -n "\$WORK_MCP_BACKUP" ]; then
|
|
220
|
+
mv "\$WORK_MCP_BACKUP" "\$WORK_MCP"
|
|
221
|
+
else
|
|
222
|
+
rm -f "\$WORK_MCP"
|
|
223
|
+
fi
|
|
224
|
+
}
|
|
225
|
+
trap cleanup EXIT
|
|
226
|
+
|
|
227
|
+
# Start claude (no positional prompt — startup instructions are in CLAUDE.md,
|
|
228
|
+
# and the auto-acceptor sends the first prompt via tmux keys once claude is ready)
|
|
229
|
+
cd "$WORK_DIR" && claude \\
|
|
230
|
+
--dangerously-skip-permissions \\
|
|
231
|
+
--dangerously-load-development-channels server:discord-filtered
|
|
232
|
+
WRAPEOF
|
|
233
|
+
chmod +x "$WRAPPER"
|
|
234
|
+
|
|
235
|
+
# Start the worker in tmux
|
|
236
|
+
tmux new-window -t "$TMUX_SESSION" -n "$WORKER_NAME" "bash '$WRAPPER'"
|
|
237
|
+
|
|
238
|
+
# Update tracking.json
|
|
239
|
+
if [ ! -f "$TRACKING" ]; then
|
|
240
|
+
echo '[]' > "$TRACKING"
|
|
241
|
+
fi
|
|
242
|
+
UPDATED=$(jq ". + [{
|
|
243
|
+
\"name\": \"$WORKER_NAME\",
|
|
244
|
+
\"channelId\": \"$CHANNEL_ID\",
|
|
245
|
+
\"workDir\": \"$WORK_DIR\",
|
|
246
|
+
\"intent\": \"$INTENT\",
|
|
247
|
+
\"status\": \"active\",
|
|
248
|
+
\"started\": \"$(date -Iseconds)\"
|
|
249
|
+
}]" "$TRACKING")
|
|
250
|
+
echo "$UPDATED" > "$TRACKING"
|
|
251
|
+
|
|
252
|
+
echo "Worker '$WORKER_NAME' spawned. Discord channel: $CHANNEL_ID"
|
|
253
|
+
echo "Talk to it in the new Discord channel."
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
3
|
+
CONFIG="$ONKOL_DIR/config.json"
|
|
4
|
+
NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
|
|
5
|
+
TMUX_SESSION="onkol-${NODE_NAME}"
|
|
6
|
+
|
|
7
|
+
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
|
|
8
|
+
echo "Session $TMUX_SESSION already running."
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
tmux new-session -d -s "$TMUX_SESSION" \
|
|
13
|
+
"cd '$ONKOL_DIR' && claude \
|
|
14
|
+
--dangerously-skip-permissions \
|
|
15
|
+
--dangerously-load-development-channels server:discord-filtered \
|
|
16
|
+
--mcp-config '$ONKOL_DIR/.mcp.json'"
|
|
17
|
+
|
|
18
|
+
# Auto-accept interactive prompts (trust dialog + dev channels warning)
|
|
19
|
+
# Background loop sends Enter every 2 seconds until claude reaches the ❯ prompt
|
|
20
|
+
(
|
|
21
|
+
for i in $(seq 1 10); do
|
|
22
|
+
sleep 2
|
|
23
|
+
PANE_CONTENT=$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || echo "")
|
|
24
|
+
if echo "$PANE_CONTENT" | grep -q "^❯"; then
|
|
25
|
+
break
|
|
26
|
+
fi
|
|
27
|
+
tmux send-keys -t "$TMUX_SESSION" Enter 2>/dev/null || true
|
|
28
|
+
done
|
|
29
|
+
) &
|
|
30
|
+
|
|
31
|
+
echo "Orchestrator started in tmux session '$TMUX_SESSION'."
|
|
32
|
+
echo "Attach with: tmux attach -t $TMUX_SESSION"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, type Message } from 'discord.js'
|
|
2
|
+
|
|
3
|
+
export interface DiscordClientConfig {
|
|
4
|
+
botToken: string
|
|
5
|
+
channelId: string
|
|
6
|
+
allowedUsers: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shouldForwardMessage(
|
|
10
|
+
messageChannelId: string,
|
|
11
|
+
authorId: string,
|
|
12
|
+
isBot: boolean,
|
|
13
|
+
targetChannelId: string,
|
|
14
|
+
allowedUsers: string[]
|
|
15
|
+
): boolean {
|
|
16
|
+
if (isBot) return false
|
|
17
|
+
if (messageChannelId !== targetChannelId) return false
|
|
18
|
+
if (allowedUsers.length > 0 && !allowedUsers.includes(authorId)) return false
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createDiscordClient(
|
|
23
|
+
config: DiscordClientConfig,
|
|
24
|
+
onMessage: (message: Message) => void
|
|
25
|
+
) {
|
|
26
|
+
const client = new Client({
|
|
27
|
+
intents: [
|
|
28
|
+
GatewayIntentBits.Guilds,
|
|
29
|
+
GatewayIntentBits.GuildMessages,
|
|
30
|
+
GatewayIntentBits.MessageContent,
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
client.on('messageCreate', (message) => {
|
|
35
|
+
if (
|
|
36
|
+
shouldForwardMessage(
|
|
37
|
+
message.channel.id,
|
|
38
|
+
message.author.id,
|
|
39
|
+
message.author.bot,
|
|
40
|
+
config.channelId,
|
|
41
|
+
config.allowedUsers
|
|
42
|
+
)
|
|
43
|
+
) {
|
|
44
|
+
onMessage(message)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
client.on('ready', () => {
|
|
49
|
+
console.error(`[discord-filtered] Connected as ${client.user?.tag}, filtering to channel ${config.channelId}`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
login: () => client.login(config.botToken),
|
|
54
|
+
client,
|
|
55
|
+
async sendMessage(channelId: string, text: string) {
|
|
56
|
+
const channel = await client.channels.fetch(channelId)
|
|
57
|
+
if (channel?.isTextBased() && 'send' in channel) {
|
|
58
|
+
await channel.send(text)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async sendMessageWithFile(channelId: string, text: string, filePath: string) {
|
|
62
|
+
const channel = await client.channels.fetch(channelId)
|
|
63
|
+
if (channel?.isTextBased() && 'send' in channel) {
|
|
64
|
+
await channel.send({ content: text, files: [{ attachment: filePath }] })
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
|
|
7
|
+
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN
|
|
8
|
+
const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID
|
|
9
|
+
const ALLOWED_USERS: string[] = JSON.parse(process.env.DISCORD_ALLOWED_USERS || '[]')
|
|
10
|
+
|
|
11
|
+
if (!BOT_TOKEN) {
|
|
12
|
+
console.error('[discord-filtered] DISCORD_BOT_TOKEN is required')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
if (!CHANNEL_ID) {
|
|
16
|
+
console.error('[discord-filtered] DISCORD_CHANNEL_ID is required')
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const discord = createDiscordClient(
|
|
21
|
+
{ botToken: BOT_TOKEN, channelId: CHANNEL_ID, allowedUsers: ALLOWED_USERS },
|
|
22
|
+
async (message) => {
|
|
23
|
+
await mcpServer.notification({
|
|
24
|
+
method: 'notifications/claude/channel',
|
|
25
|
+
params: {
|
|
26
|
+
content: message.content,
|
|
27
|
+
meta: {
|
|
28
|
+
channel_id: message.channel.id,
|
|
29
|
+
sender: message.author.username,
|
|
30
|
+
sender_id: message.author.id,
|
|
31
|
+
message_id: message.id,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const batcher = new MessageBatcher(async (text) => {
|
|
39
|
+
await discord.sendMessage(CHANNEL_ID, text)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const mcpServer = createMcpServer({
|
|
43
|
+
async reply(_channelId: string, text: string) {
|
|
44
|
+
batcher.enqueue(text)
|
|
45
|
+
},
|
|
46
|
+
async replyWithFile(_channelId: string, text: string, filePath: string) {
|
|
47
|
+
await discord.sendMessageWithFile(CHANNEL_ID, text, filePath)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
await mcpServer.connect(new StdioServerTransport())
|
|
53
|
+
await discord.login()
|
|
54
|
+
console.error(`[discord-filtered] Ready. Listening to channel ${CHANNEL_ID}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
console.error('[discord-filtered] Fatal error:', err)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
2
|
+
import {
|
|
3
|
+
ListToolsRequestSchema,
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
|
|
7
|
+
export interface McpToolHandlers {
|
|
8
|
+
reply: (channelId: string, text: string) => Promise<void>
|
|
9
|
+
replyWithFile: (channelId: string, text: string, filePath: string) => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMcpServer(handlers?: McpToolHandlers) {
|
|
13
|
+
const server = new Server(
|
|
14
|
+
{ name: 'discord-filtered', version: '0.1.0' },
|
|
15
|
+
{
|
|
16
|
+
capabilities: {
|
|
17
|
+
experimental: { 'claude/channel': {} },
|
|
18
|
+
tools: {},
|
|
19
|
+
},
|
|
20
|
+
instructions:
|
|
21
|
+
'Messages arrive as <channel source="discord-filtered">. Reply using the reply tool. Use reply_with_file to attach files.',
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const channelId = process.env.DISCORD_CHANNEL_ID || ''
|
|
26
|
+
|
|
27
|
+
const tools = [
|
|
28
|
+
{
|
|
29
|
+
name: 'reply',
|
|
30
|
+
description: 'Send a text message back to the Discord channel',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object' as const,
|
|
33
|
+
properties: {
|
|
34
|
+
text: { type: 'string', description: 'The message text to send' },
|
|
35
|
+
},
|
|
36
|
+
required: ['text'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'reply_with_file',
|
|
41
|
+
description: 'Send a text message with a file attachment to the Discord channel',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object' as const,
|
|
44
|
+
properties: {
|
|
45
|
+
text: { type: 'string', description: 'The message text to send' },
|
|
46
|
+
file_path: { type: 'string', description: 'Absolute path to the file to attach' },
|
|
47
|
+
},
|
|
48
|
+
required: ['text', 'file_path'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
54
|
+
tools,
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
58
|
+
const { name, arguments: args } = req.params
|
|
59
|
+
if (name === 'reply' && handlers) {
|
|
60
|
+
await handlers.reply(channelId, (args as any).text)
|
|
61
|
+
return { content: [{ type: 'text' as const, text: 'sent' }] }
|
|
62
|
+
}
|
|
63
|
+
if (name === 'reply_with_file' && handlers) {
|
|
64
|
+
await handlers.replyWithFile(channelId, (args as any).text, (args as any).file_path)
|
|
65
|
+
return { content: [{ type: 'text' as const, text: 'sent' }] }
|
|
66
|
+
}
|
|
67
|
+
return { content: [{ type: 'text' as const, text: `unknown tool: ${name}` }] }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Expose listTools for testing
|
|
71
|
+
;(server as any).listTools = async () => {
|
|
72
|
+
const handler = (server as any)._requestHandlers.get('tools/list')
|
|
73
|
+
if (!handler) return []
|
|
74
|
+
const result = await handler({ method: 'tools/list', params: {} }, {})
|
|
75
|
+
return result?.tools || []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return server
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const DISCORD_MAX_LENGTH = 2000
|
|
2
|
+
const TRUNCATION_SUFFIX = '\n... (truncated)'
|
|
3
|
+
|
|
4
|
+
export class MessageBatcher {
|
|
5
|
+
private buffer: string[] = []
|
|
6
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
7
|
+
private sendFn: (text: string) => Promise<void>
|
|
8
|
+
private delayMs: number
|
|
9
|
+
|
|
10
|
+
constructor(sendFn: (text: string) => Promise<void>, delayMs = 3000) {
|
|
11
|
+
this.sendFn = sendFn
|
|
12
|
+
this.delayMs = delayMs
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
enqueue(text: string): void {
|
|
16
|
+
this.buffer.push(text)
|
|
17
|
+
if (this.timer) clearTimeout(this.timer)
|
|
18
|
+
this.timer = setTimeout(() => this.flush(), this.delayMs)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async flush(): Promise<void> {
|
|
22
|
+
if (this.buffer.length === 0) return
|
|
23
|
+
let combined = this.buffer.join('\n')
|
|
24
|
+
this.buffer = []
|
|
25
|
+
this.timer = null
|
|
26
|
+
|
|
27
|
+
if (combined.length > DISCORD_MAX_LENGTH) {
|
|
28
|
+
combined = combined.slice(0, DISCORD_MAX_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await this.sendFn(combined)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
You are the Onkol orchestrator for "{{nodeName}}" on this VM.
|
|
2
|
+
|
|
3
|
+
## Your Role
|
|
4
|
+
You do NOT solve tasks yourself. You ALWAYS spawn worker Claude Code sessions using the spawn-worker.sh script.
|
|
5
|
+
|
|
6
|
+
## CRITICAL RULES — DO NOT VIOLATE
|
|
7
|
+
- NEVER run `claude` commands directly. NEVER use `claude --print`, `claude -p`, or any variation.
|
|
8
|
+
- NEVER craft your own worker spawning logic. ALWAYS use `./scripts/spawn-worker.sh`.
|
|
9
|
+
- NEVER investigate codebases, read project files, or run project commands yourself.
|
|
10
|
+
- If `spawn-worker.sh` fails, report the error to the user and ask what to do. Do NOT try to work around it.
|
|
11
|
+
- You are a DISPATCHER. Your only tools are: spawn-worker.sh, dissolve-worker.sh, list-workers.sh, check-worker.sh, and reading your own state files (config.json, registry.json, services.md, knowledge/, tracking.json).
|
|
12
|
+
|
|
13
|
+
## When a message arrives
|
|
14
|
+
1. Understand the task and its intent (fix, investigate, build, analyze, override)
|
|
15
|
+
2. Determine which project directory the task relates to (check registry.json and services.md)
|
|
16
|
+
3. Prepare a task brief with relevant context from:
|
|
17
|
+
- registry.json (secrets, endpoints, ports)
|
|
18
|
+
- services.md (what runs where, how to access logs)
|
|
19
|
+
- knowledge/ (past learnings from dissolved workers)
|
|
20
|
+
4. Run `./scripts/spawn-worker.sh` to create a worker — this is the ONLY way to create workers
|
|
21
|
+
5. Report back with the Discord channel name
|
|
22
|
+
|
|
23
|
+
## Intent Detection
|
|
24
|
+
- "fix..." / "resolve..." / "patch..." → intent: fix (autonomous — diagnose, fix, test, commit to branch)
|
|
25
|
+
- "look into..." / "investigate..." / "check why..." → intent: investigate (report only — no code changes)
|
|
26
|
+
- "add..." / "build..." / "create..." / "implement..." → intent: build (semi-autonomous — implement, test, show diff, wait for approval)
|
|
27
|
+
- "just ship it" / "deploy" / "push it" → intent: override (fully autonomous — requires confirmation before deploy)
|
|
28
|
+
- "analyze..." / "show me..." / "report on..." → intent: analyze (read-only)
|
|
29
|
+
|
|
30
|
+
## Spawning a Worker
|
|
31
|
+
```bash
|
|
32
|
+
./scripts/spawn-worker.sh \
|
|
33
|
+
--name "short-task-name" \
|
|
34
|
+
--dir "/path/to/project" \
|
|
35
|
+
--task "Full task description" \
|
|
36
|
+
--intent "fix|investigate|build|analyze|override" \
|
|
37
|
+
--context "relevant context excerpts"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Monitoring Workers
|
|
41
|
+
- Read `workers/tracking.json` to see active workers
|
|
42
|
+
- Run `./scripts/check-worker.sh --name "worker-name"` to check status
|
|
43
|
+
- Run `./scripts/list-workers.sh` to see all workers
|
|
44
|
+
|
|
45
|
+
## Dissolving Workers
|
|
46
|
+
When a worker is done or you are asked to dissolve:
|
|
47
|
+
```bash
|
|
48
|
+
./scripts/dissolve-worker.sh --name "worker-name"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## On Startup
|
|
52
|
+
Read these files to understand your current state:
|
|
53
|
+
1. config.json — who you are
|
|
54
|
+
2. registry.json — VM-specific endpoints, secrets, ports
|
|
55
|
+
3. services.md — what runs on this VM, how to access logs
|
|
56
|
+
4. workers/tracking.json — any active workers
|
|
57
|
+
5. knowledge/index.json — past learnings (include relevant ones in worker context)
|
|
58
|
+
6. state.md — any pending decisions from before restart
|
|
59
|
+
|
|
60
|
+
Then post: "{{nodeName}} is online. [N] active workers."
|
|
61
|
+
|
|
62
|
+
## Setup Prompts (First Boot)
|
|
63
|
+
After reading your state files, check if `setup-prompts.json` exists and has entries with status "pending".
|
|
64
|
+
If so, for each pending prompt:
|
|
65
|
+
1. Read the prompt
|
|
66
|
+
2. Execute it — run commands, discover information, read files as needed
|
|
67
|
+
3. Write the result to the target file (registry.json, services.md, or CLAUDE.md)
|
|
68
|
+
4. For registry.json: output must be valid JSON with key-value pairs
|
|
69
|
+
5. For services.md: output should be structured markdown documenting services
|
|
70
|
+
6. For CLAUDE.md: convert the plain language description into a well-structured CLAUDE.md with sections for project overview, tech stack, key files, dos and don'ts, deploy process
|
|
71
|
+
7. Mark the prompt's status as "completed" in setup-prompts.json
|
|
72
|
+
8. Report what was generated in the Discord channel
|
|
73
|
+
|
|
74
|
+
## Health Checks
|
|
75
|
+
Every time you receive a message, also check:
|
|
76
|
+
1. Read tracking.json for active workers
|
|
77
|
+
2. Run `tmux list-windows -t onkol-{{nodeName}}` to verify workers are alive
|
|
78
|
+
3. If a worker's window is gone, report it and ask: respawn, dissolve, or investigate?
|
|
79
|
+
|
|
80
|
+
## Adaptive Communication
|
|
81
|
+
- Quick tasks (< 5 min): just report results
|
|
82
|
+
- Medium tasks (5-15 min): report at start and finish
|
|
83
|
+
- Long tasks (15+ min): milestone updates every 10 minutes
|
|
84
|
+
- If stuck: ask immediately, block until human responds
|
|
85
|
+
|
|
86
|
+
## Worker Concurrency
|
|
87
|
+
Maximum {{maxWorkers}} concurrent workers. If at capacity, queue the task and notify.
|
|
88
|
+
|
|
89
|
+
## Important
|
|
90
|
+
- You do NOT write code yourself
|
|
91
|
+
- You do NOT access project codebases directly — that's what workers are for
|
|
92
|
+
- You do NOT run `claude` commands directly — ONLY use spawn-worker.sh
|
|
93
|
+
- You are a dispatcher and manager, nothing more
|
|
94
|
+
- All your state is in files — your conversation history is ephemeral
|
|
95
|
+
- If spawn-worker.sh fails, report the exact error. Do NOT improvise alternatives.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreCompact": [{
|
|
4
|
+
"hooks": [{
|
|
5
|
+
"type": "command",
|
|
6
|
+
"command": "echo '{\"systemMessage\": \"Before compacting: write any in-flight task state to workers/tracking.json and pending decisions to state.md\"}'"
|
|
7
|
+
}]
|
|
8
|
+
}],
|
|
9
|
+
"PostToolUse": [{
|
|
10
|
+
"matcher": "Bash",
|
|
11
|
+
"hooks": [{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "jq -r '.tool_input.command' >> {{bashLogPath}}"
|
|
14
|
+
}]
|
|
15
|
+
}]
|
|
16
|
+
},
|
|
17
|
+
"permissions": {
|
|
18
|
+
"defaultMode": "acceptEdits"
|
|
19
|
+
}
|
|
20
|
+
}
|