kimaki 0.4.36 → 0.4.37

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.
@@ -57,11 +57,11 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
57
57
  parent: kimakiAudioCategory,
58
58
  });
59
59
  getDatabase()
60
- .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
61
- .run(textChannel.id, projectDirectory, 'text');
60
+ .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
61
+ .run(textChannel.id, projectDirectory, 'text', appId);
62
62
  getDatabase()
63
- .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
64
- .run(voiceChannel.id, projectDirectory, 'voice');
63
+ .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
64
+ .run(voiceChannel.id, projectDirectory, 'voice', appId);
65
65
  return {
66
66
  textChannelId: textChannel.id,
67
67
  voiceChannelId: voiceChannel.id,
package/dist/cli.js CHANGED
@@ -484,10 +484,10 @@ async function run({ restart, addChannels }) {
484
484
  for (const { guild, channels } of kimakiChannels) {
485
485
  for (const channel of channels) {
486
486
  if (channel.kimakiDirectory) {
487
- db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text');
487
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null);
488
488
  const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
489
489
  if (voiceChannel) {
490
- db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice');
490
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null);
491
491
  }
492
492
  }
493
493
  }
@@ -749,17 +749,30 @@ cli
749
749
  // Magic prefix used to identify bot-initiated sessions.
750
750
  // The running bot will recognize this prefix and start a session.
751
751
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
752
+ // Notify-only prefix - bot won't start a session, just creates thread for notifications.
753
+ // Reply to the thread to start a session with the notification as context.
754
+ const BOT_NOTIFY_PREFIX = '📢 **Notification**';
752
755
  cli
753
- .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
756
+ .command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
757
+ .alias('start-session') // backwards compatibility
754
758
  .option('-c, --channel <channelId>', 'Discord channel ID')
755
759
  .option('-d, --project <path>', 'Project directory (alternative to --channel)')
756
- .option('-p, --prompt <prompt>', 'Initial prompt for the session')
760
+ .option('-p, --prompt <prompt>', 'Message content')
757
761
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
758
762
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
763
+ .option('--notify-only', 'Create notification thread without starting AI session')
759
764
  .action(async (options) => {
760
765
  try {
761
- let { channel: channelId, prompt, name, appId: optionAppId } = options;
766
+ let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
762
767
  const { project: projectPath } = options;
768
+ // Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
769
+ // cac parses large numbers and loses precision, so we extract the original string value
770
+ if (channelId) {
771
+ const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c');
772
+ if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
773
+ channelId = process.argv[channelArgIndex + 1];
774
+ }
775
+ }
763
776
  if (!channelId && !projectPath) {
764
777
  cliLogger.error('Either --channel or --project is required');
765
778
  process.exit(EXIT_NO_RESTART);
@@ -818,15 +831,38 @@ cli
818
831
  process.exit(EXIT_NO_RESTART);
819
832
  }
820
833
  s.start('Looking up channel for project...');
821
- // Check if channel already exists for this directory
834
+ // Check if channel already exists for this directory or a parent directory
835
+ // This allows running from subfolders of a registered project
822
836
  try {
823
837
  const db = getDatabase();
824
- const existingChannel = db
825
- .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
826
- .get(absolutePath, 'text');
838
+ // Helper to find channel for a path (prefers current bot's channel)
839
+ const findChannelForPath = (dirPath) => {
840
+ const withAppId = db
841
+ .prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
842
+ .get(dirPath, 'text', appId);
843
+ if (withAppId)
844
+ return withAppId;
845
+ return db
846
+ .prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?')
847
+ .get(dirPath, 'text');
848
+ };
849
+ // Try exact match first, then walk up parent directories
850
+ let existingChannel;
851
+ let searchPath = absolutePath;
852
+ while (searchPath !== path.dirname(searchPath)) {
853
+ existingChannel = findChannelForPath(searchPath);
854
+ if (existingChannel)
855
+ break;
856
+ searchPath = path.dirname(searchPath);
857
+ }
827
858
  if (existingChannel) {
828
859
  channelId = existingChannel.channel_id;
829
- s.message(`Found existing channel: ${channelId}`);
860
+ if (existingChannel.directory !== absolutePath) {
861
+ s.message(`Found parent project channel: ${existingChannel.directory}`);
862
+ }
863
+ else {
864
+ s.message(`Found existing channel: ${channelId}`);
865
+ }
830
866
  }
831
867
  else {
832
868
  // Need to create a new channel
@@ -846,10 +882,10 @@ cli
846
882
  });
847
883
  // Get guild from existing channels or first available
848
884
  const guild = await (async () => {
849
- // Try to find a guild from existing channels
885
+ // Try to find a guild from existing channels belonging to this bot
850
886
  const existingChannelRow = db
851
- .prepare('SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1')
852
- .get();
887
+ .prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
888
+ .get(appId);
853
889
  if (existingChannelRow) {
854
890
  try {
855
891
  const ch = await client.channels.fetch(existingChannelRow.channel_id);
@@ -861,7 +897,7 @@ cli
861
897
  // Channel might be deleted, continue
862
898
  }
863
899
  }
864
- // Fall back to first guild
900
+ // Fall back to first guild the bot is in
865
901
  const firstGuild = client.guilds.cache.first();
866
902
  if (!firstGuild) {
867
903
  throw new Error('No guild found. Add the bot to a server first.');
@@ -918,7 +954,8 @@ cli
918
954
  }
919
955
  s.message('Creating starter message...');
920
956
  // Create starter message with magic prefix
921
- // The full prompt goes in the message so the bot can read it
957
+ // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
958
+ const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
922
959
  const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
923
960
  method: 'POST',
924
961
  headers: {
@@ -926,7 +963,7 @@ cli
926
963
  'Content-Type': 'application/json',
927
964
  },
928
965
  body: JSON.stringify({
929
- content: `${BOT_SESSION_PREFIX}\n${prompt}`,
966
+ content: `${messagePrefix}\n${prompt}`,
930
967
  }),
931
968
  });
932
969
  if (!starterMessageResponse.ok) {
@@ -957,7 +994,10 @@ cli
957
994
  const threadData = (await threadResponse.json());
958
995
  s.stop('Thread created!');
959
996
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
960
- note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
997
+ const successMessage = notifyOnly
998
+ ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
999
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
1000
+ note(successMessage, '✅ Thread Created');
961
1001
  console.log(threadUrl);
962
1002
  process.exit(0);
963
1003
  }
package/dist/database.js CHANGED
@@ -50,6 +50,13 @@ export function getDatabase() {
50
50
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
51
51
  )
52
52
  `);
53
+ // Migration: add app_id column to channel_directories for multi-bot support
54
+ try {
55
+ db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`);
56
+ }
57
+ catch {
58
+ // Column already exists, ignore
59
+ }
53
60
  db.exec(`
54
61
  CREATE TABLE IF NOT EXISTS bot_api_keys (
55
62
  app_id TEXT PRIMARY KEY,
@@ -119,14 +119,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
119
119
  if (isThread) {
120
120
  const thread = channel;
121
121
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
122
- const row = getDatabase()
123
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
124
- .get(thread.id);
125
- if (!row) {
126
- discordLogger.log(`No session found for thread ${thread.id}`);
127
- return;
128
- }
129
- voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
130
122
  const parent = thread.parent;
131
123
  let projectDirectory;
132
124
  let channelAppId;
@@ -150,6 +142,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
150
142
  });
151
143
  return;
152
144
  }
145
+ const row = getDatabase()
146
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
147
+ .get(thread.id);
148
+ // No existing session - start a new one (e.g., replying to a notification thread)
149
+ if (!row) {
150
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`);
151
+ if (!projectDirectory) {
152
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
153
+ return;
154
+ }
155
+ // Include starter message (notification) as context for the session
156
+ let prompt = message.content;
157
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null);
158
+ if (starterMessage?.content) {
159
+ // Strip notification prefix if present
160
+ const notificationContent = starterMessage.content
161
+ .replace(/^📢 \*\*Notification\*\*\n?/, '')
162
+ .trim();
163
+ if (notificationContent) {
164
+ prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
165
+ }
166
+ }
167
+ await handleOpencodeSession({
168
+ prompt,
169
+ thread,
170
+ projectDirectory,
171
+ channelId: parent?.id || '',
172
+ });
173
+ return;
174
+ }
175
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
153
176
  let messageContent = message.content || '';
154
177
  let currentSessionContext;
155
178
  let lastSessionContext;
@@ -293,9 +316,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
293
316
  }
294
317
  }
295
318
  });
296
- // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
319
+ // Magic prefix used by `kimaki send` CLI command to initiate sessions
297
320
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
298
- // Handle bot-initiated threads created by `kimaki start-session`
321
+ // Handle bot-initiated threads created by `kimaki send`
299
322
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
300
323
  try {
301
324
  if (!newlyCreated) {
@@ -28,7 +28,11 @@ ${channelId
28
28
 
29
29
  To start a new thread/session in this channel programmatically, run:
30
30
 
31
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
31
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
32
+
33
+ Use --notify-only to create a notification thread without starting an AI session:
34
+
35
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
32
36
 
33
37
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
34
38
  `
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.36",
5
+ "version": "0.4.37",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -90,15 +90,15 @@ export async function createProjectChannels({
90
90
 
91
91
  getDatabase()
92
92
  .prepare(
93
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
93
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
94
94
  )
95
- .run(textChannel.id, projectDirectory, 'text')
95
+ .run(textChannel.id, projectDirectory, 'text', appId)
96
96
 
97
97
  getDatabase()
98
98
  .prepare(
99
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
99
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
100
100
  )
101
- .run(voiceChannel.id, projectDirectory, 'voice')
101
+ .run(voiceChannel.id, projectDirectory, 'voice', appId)
102
102
 
103
103
  return {
104
104
  textChannelId: textChannel.id,
package/src/cli.ts CHANGED
@@ -632,8 +632,8 @@ async function run({ restart, addChannels }: CliOptions) {
632
632
  for (const channel of channels) {
633
633
  if (channel.kimakiDirectory) {
634
634
  db.prepare(
635
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
636
- ).run(channel.id, channel.kimakiDirectory, 'text')
635
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
636
+ ).run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null)
637
637
 
638
638
  const voiceChannel = guild.channels.cache.find(
639
639
  (ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
@@ -641,8 +641,8 @@ async function run({ restart, addChannels }: CliOptions) {
641
641
 
642
642
  if (voiceChannel) {
643
643
  db.prepare(
644
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
645
- ).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
644
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
645
+ ).run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null)
646
646
  }
647
647
  }
648
648
  }
@@ -1002,17 +1002,22 @@ cli
1002
1002
  // Magic prefix used to identify bot-initiated sessions.
1003
1003
  // The running bot will recognize this prefix and start a session.
1004
1004
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
1005
+ // Notify-only prefix - bot won't start a session, just creates thread for notifications.
1006
+ // Reply to the thread to start a session with the notification as context.
1007
+ const BOT_NOTIFY_PREFIX = '📢 **Notification**'
1005
1008
 
1006
1009
  cli
1007
1010
  .command(
1008
- 'start-session',
1009
- 'Start a new session in a Discord channel (creates thread, bot handles the rest)',
1011
+ 'send',
1012
+ 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.',
1010
1013
  )
1014
+ .alias('start-session') // backwards compatibility
1011
1015
  .option('-c, --channel <channelId>', 'Discord channel ID')
1012
1016
  .option('-d, --project <path>', 'Project directory (alternative to --channel)')
1013
- .option('-p, --prompt <prompt>', 'Initial prompt for the session')
1017
+ .option('-p, --prompt <prompt>', 'Message content')
1014
1018
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
1015
1019
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1020
+ .option('--notify-only', 'Create notification thread without starting AI session')
1016
1021
  .action(
1017
1022
  async (options: {
1018
1023
  channel?: string
@@ -1020,10 +1025,20 @@ cli
1020
1025
  prompt?: string
1021
1026
  name?: string
1022
1027
  appId?: string
1028
+ notifyOnly?: boolean
1023
1029
  }) => {
1024
1030
  try {
1025
- let { channel: channelId, prompt, name, appId: optionAppId } = options
1031
+ let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options
1026
1032
  const { project: projectPath } = options
1033
+
1034
+ // Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
1035
+ // cac parses large numbers and loses precision, so we extract the original string value
1036
+ if (channelId) {
1037
+ const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c')
1038
+ if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
1039
+ channelId = process.argv[channelArgIndex + 1]
1040
+ }
1041
+ }
1027
1042
 
1028
1043
  if (!channelId && !projectPath) {
1029
1044
  cliLogger.error('Either --channel or --project is required')
@@ -1092,18 +1107,43 @@ cli
1092
1107
 
1093
1108
  s.start('Looking up channel for project...')
1094
1109
 
1095
- // Check if channel already exists for this directory
1110
+ // Check if channel already exists for this directory or a parent directory
1111
+ // This allows running from subfolders of a registered project
1096
1112
  try {
1097
1113
  const db = getDatabase()
1098
- const existingChannel = db
1099
- .prepare(
1100
- 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
1101
- )
1102
- .get(absolutePath, 'text') as { channel_id: string } | undefined
1114
+
1115
+ // Helper to find channel for a path (prefers current bot's channel)
1116
+ const findChannelForPath = (dirPath: string): { channel_id: string; directory: string } | undefined => {
1117
+ const withAppId = db
1118
+ .prepare(
1119
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1120
+ )
1121
+ .get(dirPath, 'text', appId) as { channel_id: string; directory: string } | undefined
1122
+ if (withAppId) return withAppId
1123
+
1124
+ return db
1125
+ .prepare(
1126
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?',
1127
+ )
1128
+ .get(dirPath, 'text') as { channel_id: string; directory: string } | undefined
1129
+ }
1130
+
1131
+ // Try exact match first, then walk up parent directories
1132
+ let existingChannel: { channel_id: string; directory: string } | undefined
1133
+ let searchPath = absolutePath
1134
+ while (searchPath !== path.dirname(searchPath)) {
1135
+ existingChannel = findChannelForPath(searchPath)
1136
+ if (existingChannel) break
1137
+ searchPath = path.dirname(searchPath)
1138
+ }
1103
1139
 
1104
1140
  if (existingChannel) {
1105
1141
  channelId = existingChannel.channel_id
1106
- s.message(`Found existing channel: ${channelId}`)
1142
+ if (existingChannel.directory !== absolutePath) {
1143
+ s.message(`Found parent project channel: ${existingChannel.directory}`)
1144
+ } else {
1145
+ s.message(`Found existing channel: ${channelId}`)
1146
+ }
1107
1147
  } else {
1108
1148
  // Need to create a new channel
1109
1149
  s.message('Creating new channel...')
@@ -1128,12 +1168,12 @@ cli
1128
1168
 
1129
1169
  // Get guild from existing channels or first available
1130
1170
  const guild = await (async () => {
1131
- // Try to find a guild from existing channels
1171
+ // Try to find a guild from existing channels belonging to this bot
1132
1172
  const existingChannelRow = db
1133
1173
  .prepare(
1134
- 'SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1',
1174
+ 'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
1135
1175
  )
1136
- .get() as { channel_id: string } | undefined
1176
+ .get(appId) as { channel_id: string } | undefined
1137
1177
 
1138
1178
  if (existingChannelRow) {
1139
1179
  try {
@@ -1145,7 +1185,7 @@ cli
1145
1185
  // Channel might be deleted, continue
1146
1186
  }
1147
1187
  }
1148
- // Fall back to first guild
1188
+ // Fall back to first guild the bot is in
1149
1189
  const firstGuild = client.guilds.cache.first()
1150
1190
  if (!firstGuild) {
1151
1191
  throw new Error('No guild found. Add the bot to a server first.')
@@ -1224,7 +1264,8 @@ cli
1224
1264
  s.message('Creating starter message...')
1225
1265
 
1226
1266
  // Create starter message with magic prefix
1227
- // The full prompt goes in the message so the bot can read it
1267
+ // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
1268
+ const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
1228
1269
  const starterMessageResponse = await fetch(
1229
1270
  `https://discord.com/api/v10/channels/${channelId}/messages`,
1230
1271
  {
@@ -1234,7 +1275,7 @@ cli
1234
1275
  'Content-Type': 'application/json',
1235
1276
  },
1236
1277
  body: JSON.stringify({
1237
- content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1278
+ content: `${messagePrefix}\n${prompt}`,
1238
1279
  }),
1239
1280
  },
1240
1281
  )
@@ -1278,10 +1319,11 @@ cli
1278
1319
 
1279
1320
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
1280
1321
 
1281
- note(
1282
- `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
1283
- '✅ Thread Created',
1284
- )
1322
+ const successMessage = notifyOnly
1323
+ ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
1324
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
1325
+
1326
+ note(successMessage, '✅ Thread Created')
1285
1327
 
1286
1328
  console.log(threadUrl)
1287
1329
 
package/src/database.ts CHANGED
@@ -61,6 +61,13 @@ export function getDatabase(): Database.Database {
61
61
  )
62
62
  `)
63
63
 
64
+ // Migration: add app_id column to channel_directories for multi-bot support
65
+ try {
66
+ db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
67
+ } catch {
68
+ // Column already exists, ignore
69
+ }
70
+
64
71
  db.exec(`
65
72
  CREATE TABLE IF NOT EXISTS bot_api_keys (
66
73
  app_id TEXT PRIMARY KEY,
@@ -187,17 +187,6 @@ export async function startDiscordBot({
187
187
  const thread = channel as ThreadChannel
188
188
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
189
189
 
190
- const row = getDatabase()
191
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
192
- .get(thread.id) as { session_id: string } | undefined
193
-
194
- if (!row) {
195
- discordLogger.log(`No session found for thread ${thread.id}`)
196
- return
197
- }
198
-
199
- voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
200
-
201
190
  const parent = thread.parent as TextChannel | null
202
191
  let projectDirectory: string | undefined
203
192
  let channelAppId: string | undefined
@@ -228,6 +217,43 @@ export async function startDiscordBot({
228
217
  return
229
218
  }
230
219
 
220
+ const row = getDatabase()
221
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
222
+ .get(thread.id) as { session_id: string } | undefined
223
+
224
+ // No existing session - start a new one (e.g., replying to a notification thread)
225
+ if (!row) {
226
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`)
227
+
228
+ if (!projectDirectory) {
229
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`)
230
+ return
231
+ }
232
+
233
+ // Include starter message (notification) as context for the session
234
+ let prompt = message.content
235
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null)
236
+ if (starterMessage?.content) {
237
+ // Strip notification prefix if present
238
+ const notificationContent = starterMessage.content
239
+ .replace(/^📢 \*\*Notification\*\*\n?/, '')
240
+ .trim()
241
+ if (notificationContent) {
242
+ prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
243
+ }
244
+ }
245
+
246
+ await handleOpencodeSession({
247
+ prompt,
248
+ thread,
249
+ projectDirectory,
250
+ channelId: parent?.id || '',
251
+ })
252
+ return
253
+ }
254
+
255
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
256
+
231
257
  let messageContent = message.content || ''
232
258
 
233
259
  let currentSessionContext: string | undefined
@@ -393,10 +419,10 @@ export async function startDiscordBot({
393
419
  }
394
420
  })
395
421
 
396
- // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
422
+ // Magic prefix used by `kimaki send` CLI command to initiate sessions
397
423
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
398
424
 
399
- // Handle bot-initiated threads created by `kimaki start-session`
425
+ // Handle bot-initiated threads created by `kimaki send`
400
426
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
401
427
  try {
402
428
  if (!newlyCreated) {
@@ -36,7 +36,11 @@ ${
36
36
 
37
37
  To start a new thread/session in this channel programmatically, run:
38
38
 
39
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
39
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
40
+
41
+ Use --notify-only to create a notification thread without starting an AI session:
42
+
43
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
40
44
 
41
45
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
42
46
  `