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/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 < 10; 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 1', { stdio: 'pipe' });
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.yellow(`⚠ Could not start orchestrator automatically.`));
467
- console.log(chalk.yellow(` Start manually: ${dir}/scripts/start-orchestrator.sh`));
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();
@@ -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>;
@@ -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
  }
@@ -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=forking
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
- Restart=on-failure
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
- onMessage(message);
40
+ const content = await resolveTextAttachments(message);
41
+ if (content) {
42
+ onMessage(content, message);
43
+ }
22
44
  }
23
45
  });
24
46
  client.on('ready', () => {
@@ -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
- const discord = createDiscordClient({ botToken: BOT_TOKEN, channelId: CHANNEL_ID, allowedUsers: ALLOWED_USERS }, async (message) => {
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: message.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
- let combined = this.buffer.join('\n');
20
+ const combined = this.buffer.join('\n');
22
21
  this.buffer = [];
23
22
  this.timer = null;
24
- if (combined.length > DISCORD_MAX_LENGTH) {
25
- combined = combined.slice(0, DISCORD_MAX_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
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.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
- "chalk": "^5.0.0",
26
- "commander": "^13.0.0"
27
+ "ws": "^8.20.0"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@types/node": "^22.0.0",
30
- "typescript": "^5.7.0",
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"