kimaki 0.4.6 → 0.4.7

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.js CHANGED
@@ -70,6 +70,18 @@ async function registerCommands(token, appId) {
70
70
  return option;
71
71
  })
72
72
  .toJSON(),
73
+ new SlashCommandBuilder()
74
+ .setName('accept')
75
+ .setDescription('Accept a pending permission request (this request only)')
76
+ .toJSON(),
77
+ new SlashCommandBuilder()
78
+ .setName('accept-always')
79
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
80
+ .toJSON(),
81
+ new SlashCommandBuilder()
82
+ .setName('reject')
83
+ .setDescription('Reject a pending permission request')
84
+ .toJSON(),
73
85
  ];
74
86
  const rest = new REST().setToken(token);
75
87
  try {
@@ -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) {
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.7",
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": [
@@ -43,14 +53,5 @@
43
53
  "string-dedent": "^3.0.2",
44
54
  "undici": "^7.16.0",
45
55
  "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
56
  }
56
- }
57
+ }
package/src/cli.ts CHANGED
@@ -126,6 +126,18 @@ async function registerCommands(token: string, appId: string) {
126
126
  return option
127
127
  })
128
128
  .toJSON(),
129
+ new SlashCommandBuilder()
130
+ .setName('accept')
131
+ .setDescription('Accept a pending permission request (this request only)')
132
+ .toJSON(),
133
+ new SlashCommandBuilder()
134
+ .setName('accept-always')
135
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
136
+ .toJSON(),
137
+ new SlashCommandBuilder()
138
+ .setName('reject')
139
+ .setDescription('Reject a pending permission request')
140
+ .toJSON(),
129
141
  ]
130
142
 
131
143
  const rest = new REST().setToken(token)
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) {