onkol 0.3.0 → 0.5.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/README.md +322 -0
- package/dist/cli/discord-api.d.ts +20 -0
- package/dist/cli/discord-api.js +102 -0
- package/dist/cli/index.js +205 -10
- package/dist/cli/prompts.d.ts +4 -0
- package/dist/cli/prompts.js +65 -0
- package/dist/cli/systemd.js +22 -3
- package/dist/plugin/discord-client.d.ts +1 -1
- package/dist/plugin/discord-client.js +24 -2
- package/dist/plugin/index.js +53 -2
- package/dist/plugin/message-batcher.js +47 -5
- package/package.json +7 -5
- package/scripts/spawn-worker.sh +18 -3
- package/scripts/start-orchestrator.sh +60 -13
- package/scripts/update-and-restart.sh +183 -0
- package/scripts/worker-watchdog.sh +204 -0
- package/src/plugin/discord-client.ts +28 -4
- package/src/plugin/index.ts +50 -2
- package/src/plugin/message-batcher.ts +55 -5
package/dist/cli/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync } from
|
|
|
8
8
|
import { resolve } from 'path';
|
|
9
9
|
import { execSync } from 'child_process';
|
|
10
10
|
import { runSetupPrompts } from './prompts.js';
|
|
11
|
-
import { createCategory, createChannel } from './discord-api.js';
|
|
11
|
+
import { createCategory, createChannel, validateBotToken, checkGatewayIntents } from './discord-api.js';
|
|
12
12
|
import { discoverServices, formatServicesMarkdown } from './auto-discover.js';
|
|
13
13
|
import { renderOrchestratorClaude, renderSettings } from './templates.js';
|
|
14
14
|
import { generateSystemdUnit, generateCrontab } from './systemd.js';
|
|
@@ -152,6 +152,25 @@ program
|
|
|
152
152
|
if (answers.discordUserId.trim()) {
|
|
153
153
|
allowedUsers.push(answers.discordUserId.trim());
|
|
154
154
|
}
|
|
155
|
+
// --- Validate Discord bot token and intents ---
|
|
156
|
+
if (!skip('discord')) {
|
|
157
|
+
console.log(chalk.gray('Validating Discord bot token...'));
|
|
158
|
+
const tokenCheck = await validateBotToken(answers.botToken);
|
|
159
|
+
if (!tokenCheck.ok) {
|
|
160
|
+
console.error(chalk.red(`\nFATAL: ${tokenCheck.error}`));
|
|
161
|
+
console.error(chalk.yellow('\nYour answers have been saved. Fix the issue and run `npx onkol setup` again to resume.'));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
console.log(chalk.green('✓ Bot token is valid'));
|
|
165
|
+
console.log(chalk.gray('Checking gateway intents...'));
|
|
166
|
+
const intentWarning = await checkGatewayIntents(answers.botToken);
|
|
167
|
+
if (intentWarning) {
|
|
168
|
+
console.error(chalk.red(`\nFATAL: ${intentWarning}`));
|
|
169
|
+
console.error(chalk.yellow('\nEnable the required intent and run `npx onkol setup` again to resume.'));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log(chalk.green('✓ Message Content intent is enabled'));
|
|
173
|
+
}
|
|
155
174
|
// --- CRITICAL: Create Discord category and orchestrator channel ---
|
|
156
175
|
let categoryId = checkpoint.categoryId || '';
|
|
157
176
|
let orchChannelId = checkpoint.orchChannelId || '';
|
|
@@ -193,6 +212,14 @@ program
|
|
|
193
212
|
maxWorkers: 3,
|
|
194
213
|
installDir: dir,
|
|
195
214
|
plugins: answers.plugins,
|
|
215
|
+
...(answers.watchdogProvider !== 'skip' ? {
|
|
216
|
+
watchdog: {
|
|
217
|
+
provider: answers.watchdogProvider,
|
|
218
|
+
model: answers.watchdogModel,
|
|
219
|
+
apiKey: answers.watchdogApiKey,
|
|
220
|
+
...(answers.watchdogApiUrl ? { apiUrl: answers.watchdogApiUrl } : {}),
|
|
221
|
+
},
|
|
222
|
+
} : {}),
|
|
196
223
|
};
|
|
197
224
|
writeFileSync(resolve(dir, 'config.json'), JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
198
225
|
markStep(homeDir, checkpoint, 'config');
|
|
@@ -233,6 +260,7 @@ program
|
|
|
233
260
|
DISCORD_BOT_TOKEN: answers.botToken,
|
|
234
261
|
DISCORD_CHANNEL_ID: orchChannelId,
|
|
235
262
|
DISCORD_ALLOWED_USERS: JSON.stringify(allowedUsers),
|
|
263
|
+
TMUX_TARGET: `onkol-${answers.nodeName}`,
|
|
236
264
|
},
|
|
237
265
|
},
|
|
238
266
|
},
|
|
@@ -275,7 +303,7 @@ program
|
|
|
275
303
|
console.log(chalk.gray(' Config files already written, skipping'));
|
|
276
304
|
}
|
|
277
305
|
// --- CRITICAL: Copy scripts ---
|
|
278
|
-
const requiredScripts = ['spawn-worker.sh', 'dissolve-worker.sh', 'list-workers.sh', 'check-worker.sh', 'healthcheck.sh', 'start-orchestrator.sh'];
|
|
306
|
+
const requiredScripts = ['spawn-worker.sh', 'dissolve-worker.sh', 'list-workers.sh', 'check-worker.sh', 'healthcheck.sh', 'worker-watchdog.sh', 'start-orchestrator.sh'];
|
|
279
307
|
const scriptsSource = resolve(__dirname, '../../scripts');
|
|
280
308
|
if (skip('scripts')) {
|
|
281
309
|
console.log(chalk.gray(' Scripts already installed, skipping'));
|
|
@@ -405,12 +433,16 @@ program
|
|
|
405
433
|
const timerDir = resolve(homeDir, '.config/systemd/user');
|
|
406
434
|
mkdirSync(timerDir, { recursive: true });
|
|
407
435
|
const healthcheckPath = resolve(dir, 'scripts/healthcheck.sh');
|
|
436
|
+
const watchdogPath = resolve(dir, 'scripts/worker-watchdog.sh');
|
|
408
437
|
writeFileSync(resolve(timerDir, 'onkol-healthcheck.service'), `[Unit]\nDescription=Onkol healthcheck\n[Service]\nType=oneshot\nExecStart=${healthcheckPath}\n`);
|
|
409
438
|
writeFileSync(resolve(timerDir, 'onkol-healthcheck.timer'), `[Unit]\nDescription=Onkol healthcheck every 5min\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=5min\n[Install]\nWantedBy=timers.target\n`);
|
|
439
|
+
writeFileSync(resolve(timerDir, 'onkol-worker-watchdog.service'), `[Unit]\nDescription=Onkol worker watchdog\n[Service]\nType=oneshot\nExecStart=${watchdogPath}\n`);
|
|
440
|
+
writeFileSync(resolve(timerDir, 'onkol-worker-watchdog.timer'), `[Unit]\nDescription=Onkol worker watchdog every 3min\n[Timer]\nOnBootSec=3min\nOnUnitActiveSec=3min\n[Install]\nWantedBy=timers.target\n`);
|
|
410
441
|
writeFileSync(resolve(timerDir, 'onkol-cleanup.service'), `[Unit]\nDescription=Onkol archive cleanup\n[Service]\nType=oneshot\nExecStart=/usr/bin/find ${resolve(dir, 'workers/.archive')} -maxdepth 1 -mtime +30 -exec rm -rf {} \\;\n`);
|
|
411
442
|
writeFileSync(resolve(timerDir, 'onkol-cleanup.timer'), `[Unit]\nDescription=Onkol archive cleanup daily\n[Timer]\nOnCalendar=*-*-* 04:00:00\n[Install]\nWantedBy=timers.target\n`);
|
|
412
443
|
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
413
444
|
execSync('systemctl --user enable --now onkol-healthcheck.timer', { stdio: 'pipe' });
|
|
445
|
+
execSync('systemctl --user enable --now onkol-worker-watchdog.timer', { stdio: 'pipe' });
|
|
414
446
|
execSync('systemctl --user enable --now onkol-cleanup.timer', { stdio: 'pipe' });
|
|
415
447
|
}
|
|
416
448
|
console.log(chalk.green(`✓ Systemd user timers installed (healthcheck every 5min, cleanup daily)`));
|
|
@@ -441,30 +473,49 @@ program
|
|
|
441
473
|
console.log(chalk.gray('\nStarting orchestrator...'));
|
|
442
474
|
let started = false;
|
|
443
475
|
try {
|
|
444
|
-
execSync(`sudo systemctl start onkol-${answers.nodeName}`, { stdio: 'pipe' });
|
|
445
|
-
// Wait for tmux session to appear
|
|
446
|
-
for (let i = 0; i <
|
|
476
|
+
execSync(`sudo systemctl start onkol-${answers.nodeName}`, { stdio: 'pipe', timeout: 60000 });
|
|
477
|
+
// Wait for tmux session to appear (the start script itself verifies, but double-check)
|
|
478
|
+
for (let i = 0; i < 5; i++) {
|
|
447
479
|
try {
|
|
448
480
|
execSync(`tmux has-session -t onkol-${answers.nodeName}`, { stdio: 'pipe' });
|
|
449
481
|
started = true;
|
|
450
482
|
break;
|
|
451
483
|
}
|
|
452
484
|
catch { /* not ready yet */ }
|
|
453
|
-
execSync('sleep
|
|
485
|
+
execSync('sleep 2', { stdio: 'pipe' });
|
|
454
486
|
}
|
|
455
487
|
if (started) {
|
|
456
488
|
console.log(chalk.green(`✓ Orchestrator started via systemd (tmux session "onkol-${answers.nodeName}")`));
|
|
457
489
|
}
|
|
490
|
+
else {
|
|
491
|
+
// systemctl succeeded but tmux session not visible — likely PATH or env issue
|
|
492
|
+
console.log(chalk.yellow(`⚠ systemctl started but tmux session not found. Trying direct start...`));
|
|
493
|
+
try {
|
|
494
|
+
const logs = execSync(`sudo journalctl -u onkol-${answers.nodeName} --no-pager -n 10 2>&1`, { encoding: 'utf-8' });
|
|
495
|
+
if (logs.trim())
|
|
496
|
+
console.log(chalk.gray(` Journal: ${logs.trim().split('\n').slice(-3).join('\n ')}`));
|
|
497
|
+
}
|
|
498
|
+
catch { /* ignore */ }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
503
|
+
console.log(chalk.yellow(`⚠ systemctl start failed: ${msg.split('\n')[0]}`));
|
|
458
504
|
}
|
|
459
|
-
catch { /* systemctl start failed, try direct */ }
|
|
460
505
|
if (!started) {
|
|
461
506
|
try {
|
|
462
|
-
execSync(`bash "${resolve(dir, 'scripts/start-orchestrator.sh')}"`, { stdio: 'pipe' });
|
|
507
|
+
execSync(`bash "${resolve(dir, 'scripts/start-orchestrator.sh')}"`, { stdio: 'pipe', timeout: 60000 });
|
|
508
|
+
// Verify the session is actually running
|
|
509
|
+
execSync(`tmux has-session -t onkol-${answers.nodeName}`, { stdio: 'pipe' });
|
|
510
|
+
started = true;
|
|
463
511
|
console.log(chalk.green(`✓ Orchestrator started in tmux session "onkol-${answers.nodeName}"`));
|
|
464
512
|
}
|
|
465
513
|
catch {
|
|
466
|
-
console.log(chalk.
|
|
467
|
-
console.log(chalk.yellow(`
|
|
514
|
+
console.log(chalk.red(`✗ Could not start orchestrator. The tmux session failed to stay alive.`));
|
|
515
|
+
console.log(chalk.yellow(` Debug steps:`));
|
|
516
|
+
console.log(chalk.yellow(` 1. Run manually: bash ${dir}/scripts/start-orchestrator.sh`));
|
|
517
|
+
console.log(chalk.yellow(` 2. Check: tmux attach -t onkol-${answers.nodeName}`));
|
|
518
|
+
console.log(chalk.yellow(` 3. Verify claude works: claude --version`));
|
|
468
519
|
}
|
|
469
520
|
}
|
|
470
521
|
// Setup complete — clear checkpoint
|
|
@@ -481,4 +532,148 @@ program
|
|
|
481
532
|
console.log(chalk.gray(`\n To attach to the session: tmux attach -t onkol-${answers.nodeName}`));
|
|
482
533
|
console.log(chalk.gray(` To check status: systemctl status onkol-${answers.nodeName}`));
|
|
483
534
|
});
|
|
535
|
+
program
|
|
536
|
+
.command('update')
|
|
537
|
+
.description('Update plugin + scripts and restart workers with conversation history preserved')
|
|
538
|
+
.option('--skip-update', 'Skip pulling latest npm package, just restart workers')
|
|
539
|
+
.option('--dir <path>', 'Onkol install directory', '')
|
|
540
|
+
.action(async (opts) => {
|
|
541
|
+
// Find install directory
|
|
542
|
+
let dir = opts.dir;
|
|
543
|
+
if (!dir) {
|
|
544
|
+
// Try common locations
|
|
545
|
+
const homeDir = process.env.HOME || '';
|
|
546
|
+
const candidates = [
|
|
547
|
+
resolve(homeDir, 'onkol'),
|
|
548
|
+
resolve(homeDir, '.onkol'),
|
|
549
|
+
'/opt/onkol',
|
|
550
|
+
];
|
|
551
|
+
for (const c of candidates) {
|
|
552
|
+
if (existsSync(resolve(c, 'config.json'))) {
|
|
553
|
+
dir = c;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!dir || !existsSync(resolve(dir, 'config.json'))) {
|
|
559
|
+
console.log(chalk.red('Could not find Onkol install. Use --dir <path> to specify.'));
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
const config = JSON.parse(readFileSync(resolve(dir, 'config.json'), 'utf-8'));
|
|
563
|
+
const nodeName = config.nodeName;
|
|
564
|
+
console.log(chalk.bold('=== Onkol Update & Restart ==='));
|
|
565
|
+
console.log(chalk.gray(`Node: ${nodeName}`));
|
|
566
|
+
console.log(chalk.gray(`Install dir: ${dir}`));
|
|
567
|
+
console.log('');
|
|
568
|
+
// Step 1: Update files
|
|
569
|
+
if (!opts.skipUpdate) {
|
|
570
|
+
console.log(chalk.cyan('[1/3] Updating files from npm package...'));
|
|
571
|
+
try {
|
|
572
|
+
// Find where this CLI is running from — that's the latest package
|
|
573
|
+
const pkgRoot = resolve(__dirname, '..');
|
|
574
|
+
const pluginSrc = existsSync(resolve(pkgRoot, 'src/plugin'))
|
|
575
|
+
? resolve(pkgRoot, 'src/plugin')
|
|
576
|
+
: resolve(pkgRoot, 'dist/plugin');
|
|
577
|
+
const scriptsSrc = resolve(pkgRoot, 'scripts');
|
|
578
|
+
// Copy plugin files
|
|
579
|
+
if (existsSync(pluginSrc)) {
|
|
580
|
+
const pluginDest = resolve(dir, 'plugins/discord-filtered');
|
|
581
|
+
const { readdirSync } = await import('fs');
|
|
582
|
+
for (const f of readdirSync(pluginSrc)) {
|
|
583
|
+
if (f.endsWith('.ts') || f.endsWith('.js')) {
|
|
584
|
+
copyFileSync(resolve(pluginSrc, f), resolve(pluginDest, f));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
console.log(chalk.green(' ✓ Plugin files updated'));
|
|
588
|
+
}
|
|
589
|
+
// Copy scripts
|
|
590
|
+
if (existsSync(scriptsSrc)) {
|
|
591
|
+
const { readdirSync, chmodSync } = await import('fs');
|
|
592
|
+
for (const f of readdirSync(scriptsSrc)) {
|
|
593
|
+
if (f.endsWith('.sh')) {
|
|
594
|
+
copyFileSync(resolve(scriptsSrc, f), resolve(dir, 'scripts', f));
|
|
595
|
+
chmodSync(resolve(dir, 'scripts', f), 0o755);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
console.log(chalk.green(' ✓ Scripts updated'));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
console.log(chalk.yellow(` ⚠ Update failed: ${err instanceof Error ? err.message : err}`));
|
|
603
|
+
console.log(chalk.yellow(' Continuing with restart...'));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
console.log(chalk.gray('[1/3] Skipping update (--skip-update)'));
|
|
608
|
+
}
|
|
609
|
+
console.log('');
|
|
610
|
+
// Step 2: Find active workers and their session IDs
|
|
611
|
+
console.log(chalk.cyan('[2/3] Dissolving active workers...'));
|
|
612
|
+
const trackingPath = resolve(dir, 'workers/tracking.json');
|
|
613
|
+
if (!existsSync(trackingPath)) {
|
|
614
|
+
console.log(chalk.gray(' No active workers.'));
|
|
615
|
+
console.log(chalk.green.bold('\n✓ Update complete. No workers to restart.'));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const tracking = JSON.parse(readFileSync(trackingPath, 'utf-8'));
|
|
619
|
+
const active = tracking.filter((w) => w.status === 'active');
|
|
620
|
+
if (active.length === 0) {
|
|
621
|
+
console.log(chalk.gray(' No active workers.'));
|
|
622
|
+
console.log(chalk.green.bold('\n✓ Update complete. No workers to restart.'));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const workers = [];
|
|
626
|
+
for (const w of active) {
|
|
627
|
+
// Find session ID: look in ~/.claude/projects/<encoded-path>/
|
|
628
|
+
const encoded = '-' + w.workDir.replace(/^\//, '').replace(/\//g, '-');
|
|
629
|
+
const sessionDir = resolve(process.env.HOME || '', '.claude/projects', encoded);
|
|
630
|
+
let sessionId = '';
|
|
631
|
+
try {
|
|
632
|
+
const { readdirSync, statSync } = await import('fs');
|
|
633
|
+
const jsonls = readdirSync(sessionDir)
|
|
634
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
635
|
+
.map((f) => ({ name: f, mtime: statSync(resolve(sessionDir, f)).mtimeMs }))
|
|
636
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
637
|
+
if (jsonls.length > 0) {
|
|
638
|
+
sessionId = jsonls[jsonls.length - 1].name.replace('.jsonl', '');
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch { /* session dir may not exist */ }
|
|
642
|
+
workers.push({ name: w.name, workDir: w.workDir, intent: w.intent, sessionId });
|
|
643
|
+
console.log(chalk.gray(` ${w.name} → session: ${sessionId || 'none'}`));
|
|
644
|
+
}
|
|
645
|
+
console.log('');
|
|
646
|
+
// Dissolve
|
|
647
|
+
for (const w of workers) {
|
|
648
|
+
try {
|
|
649
|
+
execSync(`bash "${resolve(dir, 'scripts/dissolve-worker.sh')}" --name "${w.name}"`, { stdio: 'pipe' });
|
|
650
|
+
console.log(chalk.gray(` ✓ ${w.name} dissolved`));
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
console.log(chalk.yellow(` ⚠ Failed to dissolve ${w.name}: ${err instanceof Error ? err.message : err}`));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
console.log('');
|
|
657
|
+
// Step 3: Respawn with --resume
|
|
658
|
+
console.log(chalk.cyan('[3/3] Respawning workers with --resume...'));
|
|
659
|
+
for (const w of workers) {
|
|
660
|
+
const resumeArg = w.sessionId ? `--resume ${w.sessionId}` : '';
|
|
661
|
+
const cmd = `bash "${resolve(dir, 'scripts/spawn-worker.sh')}" \
|
|
662
|
+
--name "${w.name}" \
|
|
663
|
+
--dir "${w.workDir}" \
|
|
664
|
+
--task "Continue the previous work. Check your conversation history for context." \
|
|
665
|
+
--intent "${w.intent}" \
|
|
666
|
+
${resumeArg}`;
|
|
667
|
+
try {
|
|
668
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
669
|
+
console.log(chalk.green(` ✓ ${w.name} respawned${w.sessionId ? ' (resumed)' : ''}`));
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
console.log(chalk.red(` ✗ Failed to spawn ${w.name}: ${err instanceof Error ? err.message : err}`));
|
|
673
|
+
}
|
|
674
|
+
// Small delay to avoid Discord rate limits
|
|
675
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
676
|
+
}
|
|
677
|
+
console.log(chalk.green.bold(`\n✓ Update complete. ${workers.length} worker(s) restarted.`));
|
|
678
|
+
});
|
|
484
679
|
program.parse();
|
package/dist/cli/prompts.d.ts
CHANGED
|
@@ -13,5 +13,9 @@ export interface SetupAnswers {
|
|
|
13
13
|
claudeMdMode: 'prompt' | 'skip';
|
|
14
14
|
claudeMdPrompt: string | null;
|
|
15
15
|
plugins: string[];
|
|
16
|
+
watchdogProvider: 'openrouter' | 'gemini' | 'custom' | 'skip';
|
|
17
|
+
watchdogModel: string | null;
|
|
18
|
+
watchdogApiKey: string | null;
|
|
19
|
+
watchdogApiUrl: string | null;
|
|
16
20
|
}
|
|
17
21
|
export declare function runSetupPrompts(homeDir: string): Promise<SetupAnswers>;
|
package/dist/cli/prompts.js
CHANGED
|
@@ -165,8 +165,70 @@ export async function runSetupPrompts(homeDir) {
|
|
|
165
165
|
{ name: 'frontend-design', value: 'frontend-design', checked: false },
|
|
166
166
|
],
|
|
167
167
|
},
|
|
168
|
+
{
|
|
169
|
+
type: 'list',
|
|
170
|
+
name: 'watchdogProvider',
|
|
171
|
+
message: 'Worker watchdog LLM (monitors workers, nudges if stuck/silent):',
|
|
172
|
+
choices: [
|
|
173
|
+
{ name: 'OpenRouter (recommended — use any model via openrouter.ai)', value: 'openrouter' },
|
|
174
|
+
{ name: 'Google Gemini (direct API)', value: 'gemini' },
|
|
175
|
+
{ name: 'Custom OpenAI-compatible endpoint', value: 'custom' },
|
|
176
|
+
{ name: 'Skip (disable LLM watchdog)', value: 'skip' },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'list',
|
|
181
|
+
name: 'watchdogModel',
|
|
182
|
+
message: 'Watchdog model:',
|
|
183
|
+
choices: (a) => {
|
|
184
|
+
const base = [
|
|
185
|
+
{ name: 'google/gemini-2.5-flash (fast, cheap)', value: 'google/gemini-2.5-flash' },
|
|
186
|
+
{ name: 'google/gemini-2.0-flash-001 (fast, cheap)', value: 'google/gemini-2.0-flash-001' },
|
|
187
|
+
{ name: 'anthropic/claude-haiku (fast)', value: 'anthropic/claude-3-5-haiku-20241022' },
|
|
188
|
+
{ name: 'Custom — enter model ID', value: '__custom__' },
|
|
189
|
+
];
|
|
190
|
+
if (a.watchdogProvider === 'gemini') {
|
|
191
|
+
return [
|
|
192
|
+
{ name: 'gemini-2.5-flash-preview-05-20 (recommended)', value: 'gemini-2.5-flash-preview-05-20' },
|
|
193
|
+
{ name: 'gemini-2.0-flash', value: 'gemini-2.0-flash' },
|
|
194
|
+
{ name: 'Custom — enter model ID', value: '__custom__' },
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
return base;
|
|
198
|
+
},
|
|
199
|
+
when: (a) => a.watchdogProvider !== 'skip',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
type: 'input',
|
|
203
|
+
name: 'watchdogModelCustom',
|
|
204
|
+
message: 'Enter model ID:',
|
|
205
|
+
when: (a) => a.watchdogProvider !== 'skip' && a.watchdogModel === '__custom__',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
type: 'password',
|
|
209
|
+
name: 'watchdogApiKey',
|
|
210
|
+
message: (a) => {
|
|
211
|
+
if (a.watchdogProvider === 'openrouter')
|
|
212
|
+
return 'OpenRouter API key (sk-or-...):';
|
|
213
|
+
if (a.watchdogProvider === 'gemini')
|
|
214
|
+
return 'Google Gemini API key:';
|
|
215
|
+
return 'API key:';
|
|
216
|
+
},
|
|
217
|
+
mask: '*',
|
|
218
|
+
when: (a) => a.watchdogProvider !== 'skip',
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: 'input',
|
|
222
|
+
name: 'watchdogApiUrl',
|
|
223
|
+
message: 'API base URL (OpenAI-compatible, e.g. https://api.example.com/v1/chat/completions):',
|
|
224
|
+
when: (a) => a.watchdogProvider === 'custom',
|
|
225
|
+
},
|
|
168
226
|
]);
|
|
169
227
|
const answers = { ...preDiscordAnswers, ...discordAndRestAnswers };
|
|
228
|
+
// Resolve custom model selection
|
|
229
|
+
const watchdogModel = answers.watchdogModel === '__custom__'
|
|
230
|
+
? (answers.watchdogModelCustom || null)
|
|
231
|
+
: (answers.watchdogModel || null);
|
|
170
232
|
return {
|
|
171
233
|
...answers,
|
|
172
234
|
registryPath: answers.registryPath || null,
|
|
@@ -174,5 +236,8 @@ export async function runSetupPrompts(homeDir) {
|
|
|
174
236
|
serviceSummaryPath: answers.serviceSummaryPath || null,
|
|
175
237
|
servicesPrompt: answers.servicesPrompt || null,
|
|
176
238
|
claudeMdPrompt: answers.claudeMdPrompt || null,
|
|
239
|
+
watchdogModel,
|
|
240
|
+
watchdogApiKey: answers.watchdogApiKey || null,
|
|
241
|
+
watchdogApiUrl: answers.watchdogApiUrl || null,
|
|
177
242
|
};
|
|
178
243
|
}
|
package/dist/cli/systemd.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
export function generateSystemdUnit(nodeName, user, onkolDir) {
|
|
2
|
+
// Resolve PATH additions for claude and bun at generation time
|
|
3
|
+
const homeDir = process.env.HOME || `/home/${user}`;
|
|
4
|
+
const extraPaths = [
|
|
5
|
+
`${homeDir}/.local/bin`,
|
|
6
|
+
`${homeDir}/.bun/bin`,
|
|
7
|
+
].filter(p => {
|
|
8
|
+
try {
|
|
9
|
+
return require('fs').existsSync(p);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
const pathEnv = extraPaths.length > 0
|
|
16
|
+
? `Environment=PATH=${extraPaths.join(':')}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
|
|
17
|
+
: '';
|
|
2
18
|
return `[Unit]
|
|
3
19
|
Description=Onkol Node: ${nodeName}
|
|
4
20
|
After=network.target
|
|
5
21
|
|
|
6
22
|
[Service]
|
|
7
|
-
Type=
|
|
23
|
+
Type=oneshot
|
|
24
|
+
RemainAfterExit=yes
|
|
8
25
|
User=${user}
|
|
26
|
+
${pathEnv}
|
|
27
|
+
Environment=HOME=${homeDir}
|
|
9
28
|
ExecStart=${onkolDir}/scripts/start-orchestrator.sh
|
|
10
29
|
ExecStop=/usr/bin/tmux kill-session -t onkol-${nodeName}
|
|
11
|
-
|
|
12
|
-
RestartSec=10
|
|
30
|
+
TimeoutStartSec=60
|
|
13
31
|
|
|
14
32
|
[Install]
|
|
15
33
|
WantedBy=multi-user.target
|
|
@@ -17,6 +35,7 @@ WantedBy=multi-user.target
|
|
|
17
35
|
}
|
|
18
36
|
export function generateCrontab(onkolDir) {
|
|
19
37
|
return `*/5 * * * * ${onkolDir}/scripts/healthcheck.sh
|
|
38
|
+
*/3 * * * * ${onkolDir}/scripts/worker-watchdog.sh
|
|
20
39
|
0 4 * * * find ${onkolDir}/workers/.archive -maxdepth 1 -mtime +30 -exec rm -rf {} \\;
|
|
21
40
|
`;
|
|
22
41
|
}
|
|
@@ -5,7 +5,7 @@ export interface DiscordClientConfig {
|
|
|
5
5
|
allowedUsers: string[];
|
|
6
6
|
}
|
|
7
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): {
|
|
8
|
+
export declare function createDiscordClient(config: DiscordClientConfig, onMessage: (content: string, message: Message) => void): {
|
|
9
9
|
login: () => Promise<string>;
|
|
10
10
|
client: Client<boolean>;
|
|
11
11
|
sendMessage(channelId: string, text: string): Promise<void>;
|
|
@@ -8,6 +8,25 @@ export function shouldForwardMessage(messageChannelId, authorId, isBot, targetCh
|
|
|
8
8
|
return false;
|
|
9
9
|
return true;
|
|
10
10
|
}
|
|
11
|
+
// When a message is too long, Discord auto-converts it to a .txt file attachment
|
|
12
|
+
// with empty message content. This fetches the text from those attachments.
|
|
13
|
+
async function resolveTextAttachments(message) {
|
|
14
|
+
let content = message.content;
|
|
15
|
+
const textAttachments = message.attachments.filter((a) => a.contentType?.startsWith('text/') || a.name?.endsWith('.txt'));
|
|
16
|
+
for (const attachment of textAttachments.values()) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(attachment.url);
|
|
19
|
+
if (res.ok) {
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
content = content ? `${content}\n\n${text}` : text;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error(`[discord-filtered] Failed to fetch attachment ${attachment.name}: ${err}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
11
30
|
export function createDiscordClient(config, onMessage) {
|
|
12
31
|
const client = new Client({
|
|
13
32
|
intents: [
|
|
@@ -16,9 +35,12 @@ export function createDiscordClient(config, onMessage) {
|
|
|
16
35
|
GatewayIntentBits.MessageContent,
|
|
17
36
|
],
|
|
18
37
|
});
|
|
19
|
-
client.on('messageCreate', (message) => {
|
|
38
|
+
client.on('messageCreate', async (message) => {
|
|
20
39
|
if (shouldForwardMessage(message.channel.id, message.author.id, message.author.bot, config.channelId, config.allowedUsers)) {
|
|
21
|
-
|
|
40
|
+
const content = await resolveTextAttachments(message);
|
|
41
|
+
if (content) {
|
|
42
|
+
onMessage(content, message);
|
|
43
|
+
}
|
|
22
44
|
}
|
|
23
45
|
});
|
|
24
46
|
client.on('ready', () => {
|
package/dist/plugin/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
3
4
|
import { createMcpServer } from './mcp-server.js';
|
|
4
5
|
import { createDiscordClient } from './discord-client.js';
|
|
5
6
|
import { MessageBatcher } from './message-batcher.js';
|
|
6
7
|
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
|
7
8
|
const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
|
|
8
9
|
const ALLOWED_USERS = JSON.parse(process.env.DISCORD_ALLOWED_USERS || '[]');
|
|
10
|
+
const TMUX_TARGET = process.env.TMUX_TARGET || '';
|
|
9
11
|
if (!BOT_TOKEN) {
|
|
10
12
|
console.error('[discord-filtered] DISCORD_BOT_TOKEN is required');
|
|
11
13
|
process.exit(1);
|
|
@@ -14,11 +16,60 @@ if (!CHANNEL_ID) {
|
|
|
14
16
|
console.error('[discord-filtered] DISCORD_CHANNEL_ID is required');
|
|
15
17
|
process.exit(1);
|
|
16
18
|
}
|
|
17
|
-
|
|
19
|
+
function sendInterrupt() {
|
|
20
|
+
if (!TMUX_TARGET) {
|
|
21
|
+
console.error('[discord-filtered] !stop received but TMUX_TARGET not set — cannot interrupt');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// Escape is Claude Code's interrupt key
|
|
26
|
+
execSync(`tmux send-keys -t ${JSON.stringify(TMUX_TARGET)} Escape`, { stdio: 'pipe' });
|
|
27
|
+
console.error(`[discord-filtered] Sent interrupt (Escape) to ${TMUX_TARGET}`);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(`[discord-filtered] Failed to send interrupt: ${err}`);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const discord = createDiscordClient({ botToken: BOT_TOKEN, channelId: CHANNEL_ID, allowedUsers: ALLOWED_USERS }, async (content, message) => {
|
|
36
|
+
// Instant acknowledgment — user knows the message reached the session
|
|
37
|
+
try {
|
|
38
|
+
await message.react('👀');
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
const isInterrupt = /^!stop\b/i.test(content);
|
|
42
|
+
if (isInterrupt) {
|
|
43
|
+
sendInterrupt();
|
|
44
|
+
// Strip the !stop prefix and forward the rest as a normal message
|
|
45
|
+
const rest = content.replace(/^!stop\s*/i, '').trim();
|
|
46
|
+
// React to confirm the interrupt was received
|
|
47
|
+
try {
|
|
48
|
+
await message.react('🛑');
|
|
49
|
+
}
|
|
50
|
+
catch { /* ignore */ }
|
|
51
|
+
// Small delay to let Claude Code process the Escape before the new message arrives
|
|
52
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
53
|
+
// Forward the message (with or without remaining text)
|
|
54
|
+
await mcpServer.notification({
|
|
55
|
+
method: 'notifications/claude/channel',
|
|
56
|
+
params: {
|
|
57
|
+
content: rest || '[interrupted by user]',
|
|
58
|
+
meta: {
|
|
59
|
+
channel_id: message.channel.id,
|
|
60
|
+
sender: message.author.username,
|
|
61
|
+
sender_id: message.author.id,
|
|
62
|
+
message_id: message.id,
|
|
63
|
+
interrupt: true,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
18
69
|
await mcpServer.notification({
|
|
19
70
|
method: 'notifications/claude/channel',
|
|
20
71
|
params: {
|
|
21
|
-
content:
|
|
72
|
+
content: content,
|
|
22
73
|
meta: {
|
|
23
74
|
channel_id: message.channel.id,
|
|
24
75
|
sender: message.author.username,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const DISCORD_MAX_LENGTH = 2000;
|
|
2
|
-
const TRUNCATION_SUFFIX = '\n... (truncated)';
|
|
3
2
|
export class MessageBatcher {
|
|
4
3
|
buffer = [];
|
|
5
4
|
timer = null;
|
|
@@ -18,12 +17,55 @@ export class MessageBatcher {
|
|
|
18
17
|
async flush() {
|
|
19
18
|
if (this.buffer.length === 0)
|
|
20
19
|
return;
|
|
21
|
-
|
|
20
|
+
const combined = this.buffer.join('\n');
|
|
22
21
|
this.buffer = [];
|
|
23
22
|
this.timer = null;
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// Split into multiple messages instead of truncating
|
|
24
|
+
const chunks = splitMessage(combined);
|
|
25
|
+
for (const chunk of chunks) {
|
|
26
|
+
await this.sendFn(chunk);
|
|
26
27
|
}
|
|
27
|
-
await this.sendFn(combined);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
// Split long text into Discord-safe chunks, preferring line breaks as split points
|
|
31
|
+
function splitMessage(text) {
|
|
32
|
+
if (text.length <= DISCORD_MAX_LENGTH)
|
|
33
|
+
return [text];
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let remaining = text;
|
|
36
|
+
while (remaining.length > 0) {
|
|
37
|
+
if (remaining.length <= DISCORD_MAX_LENGTH) {
|
|
38
|
+
chunks.push(remaining);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
// Find a good split point: prefer double newline, then single newline, then space
|
|
42
|
+
let splitAt = -1;
|
|
43
|
+
const searchWindow = remaining.slice(0, DISCORD_MAX_LENGTH);
|
|
44
|
+
// Try splitting at last paragraph break
|
|
45
|
+
const lastParagraph = searchWindow.lastIndexOf('\n\n');
|
|
46
|
+
if (lastParagraph > DISCORD_MAX_LENGTH * 0.3) {
|
|
47
|
+
splitAt = lastParagraph;
|
|
48
|
+
}
|
|
49
|
+
// Fall back to last line break
|
|
50
|
+
if (splitAt === -1) {
|
|
51
|
+
const lastNewline = searchWindow.lastIndexOf('\n');
|
|
52
|
+
if (lastNewline > DISCORD_MAX_LENGTH * 0.3) {
|
|
53
|
+
splitAt = lastNewline;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Fall back to last space
|
|
57
|
+
if (splitAt === -1) {
|
|
58
|
+
const lastSpace = searchWindow.lastIndexOf(' ');
|
|
59
|
+
if (lastSpace > DISCORD_MAX_LENGTH * 0.3) {
|
|
60
|
+
splitAt = lastSpace;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Hard split as last resort
|
|
64
|
+
if (splitAt === -1) {
|
|
65
|
+
splitAt = DISCORD_MAX_LENGTH;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
68
|
+
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
|
|
69
|
+
}
|
|
70
|
+
return chunks;
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onkol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Decentralized on-call agent system powered by Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,16 +19,18 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
22
|
+
"chalk": "^5.0.0",
|
|
23
|
+
"commander": "^13.0.0",
|
|
22
24
|
"discord.js": "^14.0.0",
|
|
23
25
|
"handlebars": "^4.7.0",
|
|
24
26
|
"inquirer": "^12.0.0",
|
|
25
|
-
"
|
|
26
|
-
"commander": "^13.0.0"
|
|
27
|
+
"ws": "^8.20.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@types/node": "^22.0.0",
|
|
30
|
-
"
|
|
31
|
-
"bun-types": "^1.2.0"
|
|
31
|
+
"@types/ws": "^8.18.1",
|
|
32
|
+
"bun-types": "^1.2.0",
|
|
33
|
+
"typescript": "^5.7.0"
|
|
32
34
|
},
|
|
33
35
|
"engines": {
|
|
34
36
|
"node": ">=18.0.0"
|