kimaki 0.4.6 → 0.4.9

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 CHANGED
@@ -1,7 +1,69 @@
1
- creating a new bot
1
+ # Kimaki Discord Bot
2
2
 
3
- - first tell user to go to https://discord.com/developers/applications and create a new app
4
- - get bot app id
5
- - get bot token in Discord clicking reset bot token. show a password input
3
+ A Discord bot that integrates OpenCode coding sessions with Discord channels and voice.
6
4
 
7
- -
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g kimaki
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ Run the interactive setup:
14
+
15
+ ```bash
16
+ kimaki
17
+ ```
18
+
19
+ This will guide you through:
20
+ 1. Creating a Discord application at https://discord.com/developers/applications
21
+ 2. Getting your bot token
22
+ 3. Installing the bot to your Discord server
23
+ 4. Creating channels for your OpenCode projects
24
+
25
+ ## Commands
26
+
27
+ ### Start the bot
28
+
29
+ ```bash
30
+ kimaki
31
+ ```
32
+
33
+ ### Send a session to Discord
34
+
35
+ Send an OpenCode session to Discord from the CLI:
36
+
37
+ ```bash
38
+ kimaki send-to-discord <session-id>
39
+ ```
40
+
41
+ Options:
42
+ - `-d, --directory <dir>` - Project directory (defaults to current working directory)
43
+
44
+ ### OpenCode Integration
45
+
46
+ To use the `/send-to-kimaki-discord` command in OpenCode:
47
+
48
+ ```bash
49
+ npx kimaki install-plugin
50
+ ```
51
+
52
+ Then use `/send-to-kimaki-discord` in OpenCode to send the current session to Discord.
53
+
54
+ ## Discord Slash Commands
55
+
56
+ Once the bot is running, you can use these commands in Discord:
57
+
58
+ - `/session <prompt>` - Start a new OpenCode session
59
+ - `/resume <session>` - Resume an existing session
60
+ - `/add-project <project>` - Add a new project to Discord
61
+ - `/accept` - Accept a permission request
62
+ - `/accept-always` - Accept and auto-approve similar requests
63
+ - `/reject` - Reject a permission request
64
+
65
+ ## Voice Support
66
+
67
+ Join a voice channel that has an associated project directory, and the bot will join with Jarvis-like voice interaction powered by Gemini.
68
+
69
+ Requires a Gemini API key (prompted during setup).
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDis
6
6
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
+ import { createRequire } from 'node:module';
10
+ import os from 'node:os';
9
11
  import { createLogger } from './logger.js';
10
12
  import { spawn, spawnSync, execSync } from 'node:child_process';
11
13
  const cliLogger = createLogger('CLI');
@@ -70,6 +72,18 @@ async function registerCommands(token, appId) {
70
72
  return option;
71
73
  })
72
74
  .toJSON(),
75
+ new SlashCommandBuilder()
76
+ .setName('accept')
77
+ .setDescription('Accept a pending permission request (this request only)')
78
+ .toJSON(),
79
+ new SlashCommandBuilder()
80
+ .setName('accept-always')
81
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
82
+ .toJSON(),
83
+ new SlashCommandBuilder()
84
+ .setName('reject')
85
+ .setDescription('Reject a pending permission request')
86
+ .toJSON(),
73
87
  ];
74
88
  const rest = new REST().setToken(token);
75
89
  try {
@@ -423,5 +437,149 @@ cli
423
437
  process.exit(EXIT_NO_RESTART);
424
438
  }
425
439
  });
440
+ cli
441
+ .command('send-to-discord <sessionId>', 'Send an OpenCode session to Discord and create a thread for it')
442
+ .option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
443
+ .action(async (sessionId, options) => {
444
+ try {
445
+ const directory = options.directory || process.cwd();
446
+ const db = getDatabase();
447
+ const botRow = db
448
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
449
+ .get();
450
+ if (!botRow) {
451
+ cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.');
452
+ process.exit(EXIT_NO_RESTART);
453
+ }
454
+ const channelRow = db
455
+ .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
456
+ .get(directory, 'text');
457
+ if (!channelRow) {
458
+ cliLogger.error(`No Discord channel found for directory: ${directory}\n` +
459
+ 'Run `kimaki --add-channels` to create a channel for this project.');
460
+ process.exit(EXIT_NO_RESTART);
461
+ }
462
+ const s = spinner();
463
+ s.start('Connecting to Discord...');
464
+ const discordClient = await createDiscordClient();
465
+ await new Promise((resolve, reject) => {
466
+ discordClient.once(Events.ClientReady, () => {
467
+ resolve();
468
+ });
469
+ discordClient.once(Events.Error, reject);
470
+ discordClient.login(botRow.token).catch(reject);
471
+ });
472
+ s.stop('Connected to Discord!');
473
+ const channel = await discordClient.channels.fetch(channelRow.channel_id);
474
+ if (!channel || channel.type !== ChannelType.GuildText) {
475
+ cliLogger.error('Could not find the text channel or it is not a text channel');
476
+ discordClient.destroy();
477
+ process.exit(EXIT_NO_RESTART);
478
+ }
479
+ const textChannel = channel;
480
+ s.start('Fetching session from OpenCode...');
481
+ const getClient = await initializeOpencodeForDirectory(directory);
482
+ const sessionResponse = await getClient().session.get({
483
+ path: { id: sessionId },
484
+ });
485
+ if (!sessionResponse.data) {
486
+ s.stop('Session not found');
487
+ discordClient.destroy();
488
+ process.exit(EXIT_NO_RESTART);
489
+ }
490
+ const session = sessionResponse.data;
491
+ s.stop(`Found session: ${session.title}`);
492
+ s.start('Creating Discord thread...');
493
+ const thread = await textChannel.threads.create({
494
+ name: `Resume: ${session.title}`.slice(0, 100),
495
+ autoArchiveDuration: 1440,
496
+ reason: `Resuming session ${sessionId} from CLI`,
497
+ });
498
+ db.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)').run(thread.id, sessionId);
499
+ s.stop('Created Discord thread!');
500
+ s.start('Loading session messages...');
501
+ const messagesResponse = await getClient().session.messages({
502
+ path: { id: sessionId },
503
+ });
504
+ if (!messagesResponse.data) {
505
+ s.stop('Failed to fetch session messages');
506
+ discordClient.destroy();
507
+ process.exit(EXIT_NO_RESTART);
508
+ }
509
+ const messages = messagesResponse.data;
510
+ await thread.send(`📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
511
+ let messageCount = 0;
512
+ for (const message of messages) {
513
+ if (message.info.role === 'user') {
514
+ const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
515
+ const userTexts = userParts
516
+ .map((p) => {
517
+ if (p.type === 'text') {
518
+ return p.text;
519
+ }
520
+ return '';
521
+ })
522
+ .filter((t) => t.trim());
523
+ const userText = userTexts.join('\n\n');
524
+ if (userText) {
525
+ const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText;
526
+ await thread.send(`**User:**\n${truncated}`);
527
+ }
528
+ }
529
+ else if (message.info.role === 'assistant') {
530
+ const textParts = message.parts.filter((p) => p.type === 'text');
531
+ const texts = textParts
532
+ .map((p) => {
533
+ if (p.type === 'text') {
534
+ return p.text;
535
+ }
536
+ return '';
537
+ })
538
+ .filter((t) => t?.trim());
539
+ if (texts.length > 0) {
540
+ const combinedText = texts.join('\n\n');
541
+ const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText;
542
+ await thread.send(truncated);
543
+ }
544
+ }
545
+ messageCount++;
546
+ }
547
+ await thread.send(`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
548
+ s.stop(`Loaded ${messageCount} messages`);
549
+ const guildId = textChannel.guildId;
550
+ const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`;
551
+ note(`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`, '✅ Success');
552
+ discordClient.destroy();
553
+ process.exit(0);
554
+ }
555
+ catch (error) {
556
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
557
+ process.exit(EXIT_NO_RESTART);
558
+ }
559
+ });
560
+ cli
561
+ .command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
562
+ .action(async () => {
563
+ try {
564
+ const require = createRequire(import.meta.url);
565
+ const pluginSrc = require.resolve('./opencode-plugin.ts');
566
+ const commandSrc = require.resolve('./opencode-command.md');
567
+ const opencodeConfig = path.join(os.homedir(), '.config', 'opencode');
568
+ const pluginDir = path.join(opencodeConfig, 'plugin');
569
+ const commandDir = path.join(opencodeConfig, 'command');
570
+ fs.mkdirSync(pluginDir, { recursive: true });
571
+ fs.mkdirSync(commandDir, { recursive: true });
572
+ const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts');
573
+ const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md');
574
+ fs.copyFileSync(pluginSrc, pluginDest);
575
+ fs.copyFileSync(commandSrc, commandDest);
576
+ note(`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`, '✅ Installed');
577
+ process.exit(0);
578
+ }
579
+ catch (error) {
580
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
581
+ process.exit(EXIT_NO_RESTART);
582
+ }
583
+ });
426
584
  cli.help();
427
585
  cli.parse();
@@ -35,6 +35,8 @@ const abortControllers = new Map();
35
35
  const voiceConnections = new Map();
36
36
  // Map of directory to retry count for server restarts
37
37
  const serverRetryCount = new Map();
38
+ // Map of thread ID to pending permission (only one pending permission per thread)
39
+ const pendingPermissions = new Map();
38
40
  let db = null;
39
41
  function convertToMono16k(buffer) {
40
42
  // Parameters
@@ -1160,6 +1162,38 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1160
1162
  }
1161
1163
  break;
1162
1164
  }
1165
+ else if (event.type === 'permission.updated') {
1166
+ const permission = event.properties;
1167
+ if (permission.sessionID !== session.id) {
1168
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
1169
+ continue;
1170
+ }
1171
+ sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
1172
+ const patternStr = Array.isArray(permission.pattern)
1173
+ ? permission.pattern.join(', ')
1174
+ : permission.pattern || '';
1175
+ const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
1176
+ `**Type:** \`${permission.type}\`\n` +
1177
+ `**Action:** ${permission.title}\n` +
1178
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
1179
+ `\nUse \`/accept\` or \`/reject\` to respond.`);
1180
+ pendingPermissions.set(thread.id, {
1181
+ permission,
1182
+ messageId: permissionMessage.id,
1183
+ directory,
1184
+ });
1185
+ }
1186
+ else if (event.type === 'permission.replied') {
1187
+ const { permissionID, response, sessionID } = event.properties;
1188
+ if (sessionID !== session.id) {
1189
+ continue;
1190
+ }
1191
+ sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
1192
+ const pending = pendingPermissions.get(thread.id);
1193
+ if (pending && pending.permission.id === permissionID) {
1194
+ pendingPermissions.delete(thread.id);
1195
+ }
1196
+ }
1163
1197
  else if (event.type === 'file.edited') {
1164
1198
  sessionLogger.log(`File edited event received`);
1165
1199
  }
@@ -1927,6 +1961,115 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1927
1961
  await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
1928
1962
  }
1929
1963
  }
1964
+ else if (command.commandName === 'accept' ||
1965
+ command.commandName === 'accept-always') {
1966
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once';
1967
+ const channel = command.channel;
1968
+ if (!channel) {
1969
+ await command.reply({
1970
+ content: 'This command can only be used in a channel',
1971
+ ephemeral: true,
1972
+ });
1973
+ return;
1974
+ }
1975
+ const isThread = [
1976
+ ChannelType.PublicThread,
1977
+ ChannelType.PrivateThread,
1978
+ ChannelType.AnnouncementThread,
1979
+ ].includes(channel.type);
1980
+ if (!isThread) {
1981
+ await command.reply({
1982
+ content: 'This command can only be used in a thread with an active session',
1983
+ ephemeral: true,
1984
+ });
1985
+ return;
1986
+ }
1987
+ const pending = pendingPermissions.get(channel.id);
1988
+ if (!pending) {
1989
+ await command.reply({
1990
+ content: 'No pending permission request in this thread',
1991
+ ephemeral: true,
1992
+ });
1993
+ return;
1994
+ }
1995
+ try {
1996
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
1997
+ await getClient().postSessionIdPermissionsPermissionId({
1998
+ path: {
1999
+ id: pending.permission.sessionID,
2000
+ permissionID: pending.permission.id,
2001
+ },
2002
+ body: {
2003
+ response: scope,
2004
+ },
2005
+ });
2006
+ pendingPermissions.delete(channel.id);
2007
+ const msg = scope === 'always'
2008
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
2009
+ : `✅ Permission **accepted**`;
2010
+ await command.reply(msg);
2011
+ sessionLogger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
2012
+ }
2013
+ catch (error) {
2014
+ voiceLogger.error('[ACCEPT] Error:', error);
2015
+ await command.reply({
2016
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2017
+ ephemeral: true,
2018
+ });
2019
+ }
2020
+ }
2021
+ else if (command.commandName === 'reject') {
2022
+ const channel = command.channel;
2023
+ if (!channel) {
2024
+ await command.reply({
2025
+ content: 'This command can only be used in a channel',
2026
+ ephemeral: true,
2027
+ });
2028
+ return;
2029
+ }
2030
+ const isThread = [
2031
+ ChannelType.PublicThread,
2032
+ ChannelType.PrivateThread,
2033
+ ChannelType.AnnouncementThread,
2034
+ ].includes(channel.type);
2035
+ if (!isThread) {
2036
+ await command.reply({
2037
+ content: 'This command can only be used in a thread with an active session',
2038
+ ephemeral: true,
2039
+ });
2040
+ return;
2041
+ }
2042
+ const pending = pendingPermissions.get(channel.id);
2043
+ if (!pending) {
2044
+ await command.reply({
2045
+ content: 'No pending permission request in this thread',
2046
+ ephemeral: true,
2047
+ });
2048
+ return;
2049
+ }
2050
+ try {
2051
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
2052
+ await getClient().postSessionIdPermissionsPermissionId({
2053
+ path: {
2054
+ id: pending.permission.sessionID,
2055
+ permissionID: pending.permission.id,
2056
+ },
2057
+ body: {
2058
+ response: 'reject',
2059
+ },
2060
+ });
2061
+ pendingPermissions.delete(channel.id);
2062
+ await command.reply(`❌ Permission **rejected**`);
2063
+ sessionLogger.log(`Permission ${pending.permission.id} rejected`);
2064
+ }
2065
+ catch (error) {
2066
+ voiceLogger.error('[REJECT] Error:', error);
2067
+ await command.reply({
2068
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2069
+ ephemeral: true,
2070
+ });
2071
+ }
2072
+ }
1930
2073
  }
1931
2074
  }
1932
2075
  catch (error) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Kimaki Discord Plugin for OpenCode
3
+ *
4
+ * Adds /send-to-kimaki-discord command that sends the current session to Discord.
5
+ *
6
+ * Installation:
7
+ * kimaki install-plugin
8
+ *
9
+ * Use in OpenCode TUI:
10
+ * /send-to-kimaki-discord
11
+ */
12
+ export const KimakiDiscordPlugin = async ({ client, $, directory, }) => {
13
+ return {
14
+ event: async ({ event }) => {
15
+ if (event.type !== 'command.executed') {
16
+ return;
17
+ }
18
+ const { name, sessionID } = event.properties;
19
+ if (name !== 'send-to-kimaki-discord') {
20
+ return;
21
+ }
22
+ if (!sessionID) {
23
+ await client.tui.showToast({
24
+ body: { message: 'No session ID available', variant: 'error' },
25
+ });
26
+ return;
27
+ }
28
+ try {
29
+ const result = await $ `npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text();
30
+ const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/);
31
+ const url = urlMatch ? urlMatch[0] : null;
32
+ await client.tui.showToast({
33
+ body: {
34
+ message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
35
+ variant: 'success',
36
+ },
37
+ });
38
+ }
39
+ catch (error) {
40
+ const message = error.stderr?.toString().trim() ||
41
+ error.stdout?.toString().trim() ||
42
+ error.message ||
43
+ String(error);
44
+ await client.tui.showToast({
45
+ body: {
46
+ message: `Failed: ${message.slice(0, 100)}`,
47
+ variant: 'error',
48
+ },
49
+ });
50
+ }
51
+ },
52
+ };
53
+ };
package/package.json CHANGED
@@ -2,7 +2,17 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.6",
5
+ "version": "0.4.9",
6
+ "scripts": {
7
+ "dev": "tsx --env-file .env src/cli.ts",
8
+ "prepublishOnly": "pnpm tsc",
9
+ "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
10
+ "watch": "tsx scripts/watch-session.ts",
11
+ "test:events": "tsx test-events.ts",
12
+ "pcm-to-mp3": "bun scripts/pcm-to-mp3",
13
+ "test:send": "tsx send-test-message.ts",
14
+ "register-commands": "tsx scripts/register-commands.ts"
15
+ },
6
16
  "repository": "https://github.com/remorses/kimaki",
7
17
  "bin": "bin.js",
8
18
  "files": [
@@ -11,6 +21,7 @@
11
21
  "bin.js"
12
22
  ],
13
23
  "devDependencies": {
24
+ "@opencode-ai/plugin": "^1.0.119",
14
25
  "@types/better-sqlite3": "^7.6.13",
15
26
  "@types/bun": "latest",
16
27
  "@types/js-yaml": "^4.0.9",
@@ -43,14 +54,5 @@
43
54
  "string-dedent": "^3.0.2",
44
55
  "undici": "^7.16.0",
45
56
  "zod": "^4.0.17"
46
- },
47
- "scripts": {
48
- "dev": "tsx --env-file .env src/cli.ts",
49
- "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
50
- "watch": "tsx scripts/watch-session.ts",
51
- "test:events": "tsx test-events.ts",
52
- "pcm-to-mp3": "bun scripts/pcm-to-mp3",
53
- "test:send": "tsx send-test-message.ts",
54
- "register-commands": "tsx scripts/register-commands.ts"
55
57
  }
56
- }
58
+ }
package/src/cli.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  } from 'discord.js'
37
37
  import path from 'node:path'
38
38
  import fs from 'node:fs'
39
+ import { createRequire } from 'node:module'
40
+ import os from 'node:os'
39
41
  import { createLogger } from './logger.js'
40
42
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
41
43
 
@@ -126,6 +128,18 @@ async function registerCommands(token: string, appId: string) {
126
128
  return option
127
129
  })
128
130
  .toJSON(),
131
+ new SlashCommandBuilder()
132
+ .setName('accept')
133
+ .setDescription('Accept a pending permission request (this request only)')
134
+ .toJSON(),
135
+ new SlashCommandBuilder()
136
+ .setName('accept-always')
137
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
138
+ .toJSON(),
139
+ new SlashCommandBuilder()
140
+ .setName('reject')
141
+ .setDescription('Reject a pending permission request')
142
+ .toJSON(),
129
143
  ]
130
144
 
131
145
  const rest = new REST().setToken(token)
@@ -643,5 +657,215 @@ cli
643
657
  }
644
658
  })
645
659
 
660
+ cli
661
+ .command(
662
+ 'send-to-discord <sessionId>',
663
+ 'Send an OpenCode session to Discord and create a thread for it',
664
+ )
665
+ .option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
666
+ .action(async (sessionId: string, options: { directory?: string }) => {
667
+ try {
668
+ const directory = options.directory || process.cwd()
669
+
670
+ const db = getDatabase()
671
+
672
+ const botRow = db
673
+ .prepare(
674
+ 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
675
+ )
676
+ .get() as { app_id: string; token: string } | undefined
677
+
678
+ if (!botRow) {
679
+ cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
680
+ process.exit(EXIT_NO_RESTART)
681
+ }
682
+
683
+ const channelRow = db
684
+ .prepare(
685
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
686
+ )
687
+ .get(directory, 'text') as { channel_id: string } | undefined
688
+
689
+ if (!channelRow) {
690
+ cliLogger.error(
691
+ `No Discord channel found for directory: ${directory}\n` +
692
+ 'Run `kimaki --add-channels` to create a channel for this project.',
693
+ )
694
+ process.exit(EXIT_NO_RESTART)
695
+ }
696
+
697
+ const s = spinner()
698
+ s.start('Connecting to Discord...')
699
+
700
+ const discordClient = await createDiscordClient()
701
+
702
+ await new Promise<void>((resolve, reject) => {
703
+ discordClient.once(Events.ClientReady, () => {
704
+ resolve()
705
+ })
706
+ discordClient.once(Events.Error, reject)
707
+ discordClient.login(botRow.token).catch(reject)
708
+ })
709
+
710
+ s.stop('Connected to Discord!')
711
+
712
+ const channel = await discordClient.channels.fetch(channelRow.channel_id)
713
+ if (!channel || channel.type !== ChannelType.GuildText) {
714
+ cliLogger.error('Could not find the text channel or it is not a text channel')
715
+ discordClient.destroy()
716
+ process.exit(EXIT_NO_RESTART)
717
+ }
718
+
719
+ const textChannel = channel as import('discord.js').TextChannel
720
+
721
+ s.start('Fetching session from OpenCode...')
722
+
723
+ const getClient = await initializeOpencodeForDirectory(directory)
724
+ const sessionResponse = await getClient().session.get({
725
+ path: { id: sessionId },
726
+ })
727
+
728
+ if (!sessionResponse.data) {
729
+ s.stop('Session not found')
730
+ discordClient.destroy()
731
+ process.exit(EXIT_NO_RESTART)
732
+ }
733
+
734
+ const session = sessionResponse.data
735
+ s.stop(`Found session: ${session.title}`)
736
+
737
+ s.start('Creating Discord thread...')
738
+
739
+ const thread = await textChannel.threads.create({
740
+ name: `Resume: ${session.title}`.slice(0, 100),
741
+ autoArchiveDuration: 1440,
742
+ reason: `Resuming session ${sessionId} from CLI`,
743
+ })
744
+
745
+ db.prepare(
746
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
747
+ ).run(thread.id, sessionId)
748
+
749
+ s.stop('Created Discord thread!')
750
+
751
+ s.start('Loading session messages...')
752
+
753
+ const messagesResponse = await getClient().session.messages({
754
+ path: { id: sessionId },
755
+ })
756
+
757
+ if (!messagesResponse.data) {
758
+ s.stop('Failed to fetch session messages')
759
+ discordClient.destroy()
760
+ process.exit(EXIT_NO_RESTART)
761
+ }
762
+
763
+ const messages = messagesResponse.data
764
+
765
+ await thread.send(
766
+ `📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
767
+ )
768
+
769
+ let messageCount = 0
770
+ for (const message of messages) {
771
+ if (message.info.role === 'user') {
772
+ const userParts = message.parts.filter(
773
+ (p) => p.type === 'text' && !p.synthetic,
774
+ )
775
+ const userTexts = userParts
776
+ .map((p) => {
777
+ if (p.type === 'text') {
778
+ return p.text
779
+ }
780
+ return ''
781
+ })
782
+ .filter((t) => t.trim())
783
+
784
+ const userText = userTexts.join('\n\n')
785
+ if (userText) {
786
+ const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText
787
+ await thread.send(`**User:**\n${truncated}`)
788
+ }
789
+ } else if (message.info.role === 'assistant') {
790
+ const textParts = message.parts.filter((p) => p.type === 'text')
791
+ const texts = textParts
792
+ .map((p) => {
793
+ if (p.type === 'text') {
794
+ return p.text
795
+ }
796
+ return ''
797
+ })
798
+ .filter((t) => t?.trim())
799
+
800
+ if (texts.length > 0) {
801
+ const combinedText = texts.join('\n\n')
802
+ const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText
803
+ await thread.send(truncated)
804
+ }
805
+ }
806
+ messageCount++
807
+ }
808
+
809
+ await thread.send(
810
+ `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
811
+ )
812
+
813
+ s.stop(`Loaded ${messageCount} messages`)
814
+
815
+ const guildId = textChannel.guildId
816
+ const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`
817
+
818
+ note(
819
+ `Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`,
820
+ '✅ Success',
821
+ )
822
+
823
+ discordClient.destroy()
824
+ process.exit(0)
825
+ } catch (error) {
826
+ cliLogger.error(
827
+ 'Error:',
828
+ error instanceof Error ? error.message : String(error),
829
+ )
830
+ process.exit(EXIT_NO_RESTART)
831
+ }
832
+ })
833
+
834
+ cli
835
+ .command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
836
+ .action(async () => {
837
+ try {
838
+ const require = createRequire(import.meta.url)
839
+ const pluginSrc = require.resolve('./opencode-plugin.ts')
840
+ const commandSrc = require.resolve('./opencode-command.md')
841
+
842
+ const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
843
+ const pluginDir = path.join(opencodeConfig, 'plugin')
844
+ const commandDir = path.join(opencodeConfig, 'command')
845
+
846
+ fs.mkdirSync(pluginDir, { recursive: true })
847
+ fs.mkdirSync(commandDir, { recursive: true })
848
+
849
+ const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts')
850
+ const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
851
+
852
+ fs.copyFileSync(pluginSrc, pluginDest)
853
+ fs.copyFileSync(commandSrc, commandDest)
854
+
855
+ note(
856
+ `Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`,
857
+ '✅ Installed',
858
+ )
859
+
860
+ process.exit(0)
861
+ } catch (error) {
862
+ cliLogger.error(
863
+ 'Error:',
864
+ error instanceof Error ? error.message : String(error),
865
+ )
866
+ process.exit(EXIT_NO_RESTART)
867
+ }
868
+ })
869
+
646
870
  cli.help()
647
871
  cli.parse()
package/src/discordBot.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type Part,
5
5
  type Config,
6
6
  type FilePartInput,
7
+ type Permission,
7
8
  } from '@opencode-ai/sdk'
8
9
 
9
10
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -90,6 +91,12 @@ const voiceConnections = new Map<
90
91
  // Map of directory to retry count for server restarts
91
92
  const serverRetryCount = new Map<string, number>()
92
93
 
94
+ // Map of thread ID to pending permission (only one pending permission per thread)
95
+ const pendingPermissions = new Map<
96
+ string,
97
+ { permission: Permission; messageId: string; directory: string }
98
+ >()
99
+
93
100
  let db: Database.Database | null = null
94
101
 
95
102
  function convertToMono16k(buffer: Buffer): Buffer {
@@ -1547,6 +1554,51 @@ async function handleOpencodeSession({
1547
1554
  )
1548
1555
  }
1549
1556
  break
1557
+ } else if (event.type === 'permission.updated') {
1558
+ const permission = event.properties
1559
+ if (permission.sessionID !== session.id) {
1560
+ voiceLogger.log(
1561
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
1562
+ )
1563
+ continue
1564
+ }
1565
+
1566
+ sessionLogger.log(
1567
+ `Permission requested: type=${permission.type}, title=${permission.title}`,
1568
+ )
1569
+
1570
+ const patternStr = Array.isArray(permission.pattern)
1571
+ ? permission.pattern.join(', ')
1572
+ : permission.pattern || ''
1573
+
1574
+ const permissionMessage = await sendThreadMessage(
1575
+ thread,
1576
+ `⚠️ **Permission Required**\n\n` +
1577
+ `**Type:** \`${permission.type}\`\n` +
1578
+ `**Action:** ${permission.title}\n` +
1579
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
1580
+ `\nUse \`/accept\` or \`/reject\` to respond.`,
1581
+ )
1582
+
1583
+ pendingPermissions.set(thread.id, {
1584
+ permission,
1585
+ messageId: permissionMessage.id,
1586
+ directory,
1587
+ })
1588
+ } else if (event.type === 'permission.replied') {
1589
+ const { permissionID, response, sessionID } = event.properties
1590
+ if (sessionID !== session.id) {
1591
+ continue
1592
+ }
1593
+
1594
+ sessionLogger.log(
1595
+ `Permission ${permissionID} replied with: ${response}`,
1596
+ )
1597
+
1598
+ const pending = pendingPermissions.get(thread.id)
1599
+ if (pending && pending.permission.id === permissionID) {
1600
+ pendingPermissions.delete(thread.id)
1601
+ }
1550
1602
  } else if (event.type === 'file.edited') {
1551
1603
  sessionLogger.log(`File edited event received`)
1552
1604
  } else {
@@ -2599,6 +2651,128 @@ export async function startDiscordBot({
2599
2651
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2600
2652
  )
2601
2653
  }
2654
+ } else if (
2655
+ command.commandName === 'accept' ||
2656
+ command.commandName === 'accept-always'
2657
+ ) {
2658
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once'
2659
+ const channel = command.channel
2660
+
2661
+ if (!channel) {
2662
+ await command.reply({
2663
+ content: 'This command can only be used in a channel',
2664
+ ephemeral: true,
2665
+ })
2666
+ return
2667
+ }
2668
+
2669
+ const isThread = [
2670
+ ChannelType.PublicThread,
2671
+ ChannelType.PrivateThread,
2672
+ ChannelType.AnnouncementThread,
2673
+ ].includes(channel.type)
2674
+
2675
+ if (!isThread) {
2676
+ await command.reply({
2677
+ content: 'This command can only be used in a thread with an active session',
2678
+ ephemeral: true,
2679
+ })
2680
+ return
2681
+ }
2682
+
2683
+ const pending = pendingPermissions.get(channel.id)
2684
+ if (!pending) {
2685
+ await command.reply({
2686
+ content: 'No pending permission request in this thread',
2687
+ ephemeral: true,
2688
+ })
2689
+ return
2690
+ }
2691
+
2692
+ try {
2693
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
2694
+ await getClient().postSessionIdPermissionsPermissionId({
2695
+ path: {
2696
+ id: pending.permission.sessionID,
2697
+ permissionID: pending.permission.id,
2698
+ },
2699
+ body: {
2700
+ response: scope,
2701
+ },
2702
+ })
2703
+
2704
+ pendingPermissions.delete(channel.id)
2705
+ const msg =
2706
+ scope === 'always'
2707
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
2708
+ : `✅ Permission **accepted**`
2709
+ await command.reply(msg)
2710
+ sessionLogger.log(
2711
+ `Permission ${pending.permission.id} accepted with scope: ${scope}`,
2712
+ )
2713
+ } catch (error) {
2714
+ voiceLogger.error('[ACCEPT] Error:', error)
2715
+ await command.reply({
2716
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2717
+ ephemeral: true,
2718
+ })
2719
+ }
2720
+ } else if (command.commandName === 'reject') {
2721
+ const channel = command.channel
2722
+
2723
+ if (!channel) {
2724
+ await command.reply({
2725
+ content: 'This command can only be used in a channel',
2726
+ ephemeral: true,
2727
+ })
2728
+ return
2729
+ }
2730
+
2731
+ const isThread = [
2732
+ ChannelType.PublicThread,
2733
+ ChannelType.PrivateThread,
2734
+ ChannelType.AnnouncementThread,
2735
+ ].includes(channel.type)
2736
+
2737
+ if (!isThread) {
2738
+ await command.reply({
2739
+ content: 'This command can only be used in a thread with an active session',
2740
+ ephemeral: true,
2741
+ })
2742
+ return
2743
+ }
2744
+
2745
+ const pending = pendingPermissions.get(channel.id)
2746
+ if (!pending) {
2747
+ await command.reply({
2748
+ content: 'No pending permission request in this thread',
2749
+ ephemeral: true,
2750
+ })
2751
+ return
2752
+ }
2753
+
2754
+ try {
2755
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
2756
+ await getClient().postSessionIdPermissionsPermissionId({
2757
+ path: {
2758
+ id: pending.permission.sessionID,
2759
+ permissionID: pending.permission.id,
2760
+ },
2761
+ body: {
2762
+ response: 'reject',
2763
+ },
2764
+ })
2765
+
2766
+ pendingPermissions.delete(channel.id)
2767
+ await command.reply(`❌ Permission **rejected**`)
2768
+ sessionLogger.log(`Permission ${pending.permission.id} rejected`)
2769
+ } catch (error) {
2770
+ voiceLogger.error('[REJECT] Error:', error)
2771
+ await command.reply({
2772
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2773
+ ephemeral: true,
2774
+ })
2775
+ }
2602
2776
  }
2603
2777
  }
2604
2778
  } catch (error) {
@@ -0,0 +1,4 @@
1
+ ---
2
+ description: Send current session to Kimaki Discord
3
+ ---
4
+ say "Session sent to discord!"
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Kimaki Discord Plugin for OpenCode
3
+ *
4
+ * Adds /send-to-kimaki-discord command that sends the current session to Discord.
5
+ *
6
+ * Installation:
7
+ * kimaki install-plugin
8
+ *
9
+ * Use in OpenCode TUI:
10
+ * /send-to-kimaki-discord
11
+ */
12
+
13
+ import type { Plugin } from '@opencode-ai/plugin'
14
+
15
+ export const KimakiDiscordPlugin: Plugin = async ({
16
+ client,
17
+ $,
18
+ directory,
19
+ }) => {
20
+ return {
21
+ event: async ({ event }) => {
22
+ if (event.type !== 'command.executed') {
23
+ return
24
+ }
25
+
26
+ const { name, sessionID } = event.properties as {
27
+ name: string
28
+ sessionID: string
29
+ }
30
+
31
+ if (name !== 'send-to-kimaki-discord') {
32
+ return
33
+ }
34
+
35
+ if (!sessionID) {
36
+ await client.tui.showToast({
37
+ body: { message: 'No session ID available', variant: 'error' },
38
+ })
39
+ return
40
+ }
41
+
42
+ try {
43
+ const result =
44
+ await $`npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text()
45
+
46
+ const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/)
47
+ const url = urlMatch ? urlMatch[0] : null
48
+
49
+ await client.tui.showToast({
50
+ body: {
51
+ message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
52
+ variant: 'success',
53
+ },
54
+ })
55
+ } catch (error: any) {
56
+ const message =
57
+ error.stderr?.toString().trim() ||
58
+ error.stdout?.toString().trim() ||
59
+ error.message ||
60
+ String(error)
61
+
62
+ await client.tui.showToast({
63
+ body: {
64
+ message: `Failed: ${message.slice(0, 100)}`,
65
+ variant: 'error',
66
+ },
67
+ })
68
+ }
69
+ },
70
+ }
71
+ }