kimaki 0.4.29 → 0.4.30

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
@@ -13,6 +13,7 @@ import { createLogger } from './logger.js';
13
13
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
14
  import http from 'node:http';
15
15
  import { setDataDir, getDataDir, getLockPort } from './config.js';
16
+ import { extractTagsArrays } from './xml.js';
16
17
  const cliLogger = createLogger('CLI');
17
18
  const cli = cac('kimaki');
18
19
  process.title = 'kimaki';
@@ -683,5 +684,149 @@ cli
683
684
  process.exit(EXIT_NO_RESTART);
684
685
  }
685
686
  });
687
+ // Magic prefix used to identify bot-initiated sessions.
688
+ // The running bot will recognize this prefix and start a session.
689
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
690
+ cli
691
+ .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
692
+ .option('-c, --channel <channelId>', 'Discord channel ID')
693
+ .option('-p, --prompt <prompt>', 'Initial prompt for the session')
694
+ .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
695
+ .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
696
+ .action(async (options) => {
697
+ try {
698
+ const { channel: channelId, prompt, name, appId: optionAppId } = options;
699
+ if (!channelId) {
700
+ cliLogger.error('Channel ID is required. Use --channel <channelId>');
701
+ process.exit(EXIT_NO_RESTART);
702
+ }
703
+ if (!prompt) {
704
+ cliLogger.error('Prompt is required. Use --prompt <prompt>');
705
+ process.exit(EXIT_NO_RESTART);
706
+ }
707
+ // Get bot token from env var or database
708
+ const envToken = process.env.KIMAKI_BOT_TOKEN;
709
+ let botToken;
710
+ let appId = optionAppId;
711
+ if (envToken) {
712
+ botToken = envToken;
713
+ if (!appId) {
714
+ // Try to get app_id from database if available (optional in CI)
715
+ try {
716
+ const db = getDatabase();
717
+ const botRow = db
718
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
719
+ .get();
720
+ appId = botRow?.app_id;
721
+ }
722
+ catch {
723
+ // Database might not exist in CI, that's ok
724
+ }
725
+ }
726
+ }
727
+ else {
728
+ // Fall back to database
729
+ try {
730
+ const db = getDatabase();
731
+ const botRow = db
732
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
733
+ .get();
734
+ if (botRow) {
735
+ botToken = botRow.token;
736
+ appId = appId || botRow.app_id;
737
+ }
738
+ }
739
+ catch (e) {
740
+ // Database error - will fall through to the check below
741
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
742
+ }
743
+ }
744
+ if (!botToken) {
745
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
746
+ process.exit(EXIT_NO_RESTART);
747
+ }
748
+ const s = spinner();
749
+ s.start('Fetching channel info...');
750
+ // Get channel info to extract directory from topic
751
+ const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
752
+ headers: {
753
+ 'Authorization': `Bot ${botToken}`,
754
+ },
755
+ });
756
+ if (!channelResponse.ok) {
757
+ const error = await channelResponse.text();
758
+ s.stop('Failed to fetch channel');
759
+ throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
760
+ }
761
+ const channelData = await channelResponse.json();
762
+ if (!channelData.topic) {
763
+ s.stop('Channel has no topic');
764
+ throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
765
+ }
766
+ const extracted = extractTagsArrays({
767
+ xml: channelData.topic,
768
+ tags: ['kimaki.directory', 'kimaki.app'],
769
+ });
770
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
771
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
772
+ if (!projectDirectory) {
773
+ s.stop('No kimaki.directory tag found');
774
+ throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
775
+ }
776
+ // Verify app ID matches if both are present
777
+ if (channelAppId && appId && channelAppId !== appId) {
778
+ s.stop('Channel belongs to different bot');
779
+ throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
780
+ }
781
+ s.message('Creating starter message...');
782
+ // Create starter message with magic prefix
783
+ // The full prompt goes in the message so the bot can read it
784
+ const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
785
+ method: 'POST',
786
+ headers: {
787
+ 'Authorization': `Bot ${botToken}`,
788
+ 'Content-Type': 'application/json',
789
+ },
790
+ body: JSON.stringify({
791
+ content: `${BOT_SESSION_PREFIX}\n${prompt}`,
792
+ }),
793
+ });
794
+ if (!starterMessageResponse.ok) {
795
+ const error = await starterMessageResponse.text();
796
+ s.stop('Failed to create message');
797
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
798
+ }
799
+ const starterMessage = await starterMessageResponse.json();
800
+ s.message('Creating thread...');
801
+ // Create thread from the message
802
+ const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
803
+ const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
804
+ method: 'POST',
805
+ headers: {
806
+ 'Authorization': `Bot ${botToken}`,
807
+ 'Content-Type': 'application/json',
808
+ },
809
+ body: JSON.stringify({
810
+ name: threadName.slice(0, 100),
811
+ auto_archive_duration: 1440, // 1 day
812
+ }),
813
+ });
814
+ if (!threadResponse.ok) {
815
+ const error = await threadResponse.text();
816
+ s.stop('Failed to create thread');
817
+ throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
818
+ }
819
+ const threadData = await threadResponse.json();
820
+ s.stop('Thread created!');
821
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
822
+ note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
823
+ console.log(threadUrl);
824
+ process.exit(0);
825
+ }
826
+ catch (error) {
827
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
828
+ process.exit(EXIT_NO_RESTART);
829
+ }
830
+ });
686
831
  cli.help();
687
832
  cli.parse();
@@ -296,6 +296,85 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
296
296
  }
297
297
  }
298
298
  });
299
+ // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
300
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
301
+ // Handle bot-initiated threads created by `kimaki start-session`
302
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
303
+ try {
304
+ if (!newlyCreated) {
305
+ return;
306
+ }
307
+ // Only handle threads in text channels
308
+ const parent = thread.parent;
309
+ if (!parent || parent.type !== ChannelType.GuildText) {
310
+ return;
311
+ }
312
+ // Get the starter message to check for magic prefix
313
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null);
314
+ if (!starterMessage) {
315
+ discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
316
+ return;
317
+ }
318
+ // Only handle messages from this bot with the magic prefix
319
+ if (starterMessage.author.id !== discordClient.user?.id) {
320
+ return;
321
+ }
322
+ if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
323
+ return;
324
+ }
325
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
326
+ // Extract the prompt (everything after the prefix)
327
+ const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
328
+ if (!prompt) {
329
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
330
+ return;
331
+ }
332
+ // Extract directory from parent channel topic
333
+ if (!parent.topic) {
334
+ discordLogger.log(`[BOT_SESSION] Parent channel has no topic`);
335
+ return;
336
+ }
337
+ const extracted = extractTagsArrays({
338
+ xml: parent.topic,
339
+ tags: ['kimaki.directory', 'kimaki.app'],
340
+ });
341
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
342
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
343
+ if (!projectDirectory) {
344
+ discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`);
345
+ return;
346
+ }
347
+ if (channelAppId && channelAppId !== currentAppId) {
348
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
349
+ return;
350
+ }
351
+ if (!fs.existsSync(projectDirectory)) {
352
+ discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
353
+ await thread.send({
354
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
355
+ flags: SILENT_MESSAGE_FLAGS,
356
+ });
357
+ return;
358
+ }
359
+ discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
360
+ await handleOpencodeSession({
361
+ prompt,
362
+ thread,
363
+ projectDirectory,
364
+ channelId: parent.id,
365
+ });
366
+ }
367
+ catch (error) {
368
+ voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error);
369
+ try {
370
+ const errMsg = error instanceof Error ? error.message : String(error);
371
+ await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
372
+ }
373
+ catch {
374
+ // Ignore send errors
375
+ }
376
+ }
377
+ });
299
378
  await discordClient.login(token);
300
379
  const handleShutdown = async (signal, { skipExit = false } = {}) => {
301
380
  discordLogger.log(`Received ${signal}, cleaning up...`);
@@ -525,7 +525,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
525
525
  path: { id: session.id },
526
526
  body: {
527
527
  parts,
528
- system: getOpencodeSystemMessage({ sessionId: session.id }),
528
+ system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
529
529
  model: modelParam,
530
530
  agent: agentPreference,
531
531
  },
@@ -1,13 +1,13 @@
1
1
  // OpenCode system prompt generator.
2
2
  // Creates the system message injected into every OpenCode session,
3
3
  // including Discord-specific formatting rules, diff commands, and permissions info.
4
- export function getOpencodeSystemMessage({ sessionId }) {
4
+ export function getOpencodeSystemMessage({ sessionId, channelId }) {
5
5
  return `
6
6
  The user is reading your messages from inside Discord, via kimaki.xyz
7
7
 
8
8
  The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
9
9
 
10
- Your current OpenCode session ID is: ${sessionId}
10
+ Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
11
11
 
12
12
  ## permissions
13
13
 
@@ -22,7 +22,15 @@ Only users with these Discord permissions can send messages to the bot:
22
22
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
23
23
 
24
24
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
25
+ ${channelId ? `
26
+ ## starting new sessions from CLI
25
27
 
28
+ To start a new thread/session in this channel programmatically, run:
29
+
30
+ npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
31
+
32
+ This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
33
+ ` : ''}
26
34
  ## showing diffs
27
35
 
28
36
  IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
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.29",
5
+ "version": "0.4.30",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/cli.ts CHANGED
@@ -46,6 +46,7 @@ import { createLogger } from './logger.js'
46
46
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
47
47
  import http from 'node:http'
48
48
  import { setDataDir, getDataDir, getLockPort } from './config.js'
49
+ import { extractTagsArrays } from './xml.js'
49
50
 
50
51
  const cliLogger = createLogger('CLI')
51
52
  const cli = cac('kimaki')
@@ -958,6 +959,196 @@ cli
958
959
  })
959
960
 
960
961
 
962
+ // Magic prefix used to identify bot-initiated sessions.
963
+ // The running bot will recognize this prefix and start a session.
964
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
965
+
966
+ cli
967
+ .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
968
+ .option('-c, --channel <channelId>', 'Discord channel ID')
969
+ .option('-p, --prompt <prompt>', 'Initial prompt for the session')
970
+ .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
971
+ .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
972
+ .action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
973
+ try {
974
+ const { channel: channelId, prompt, name, appId: optionAppId } = options
975
+
976
+ if (!channelId) {
977
+ cliLogger.error('Channel ID is required. Use --channel <channelId>')
978
+ process.exit(EXIT_NO_RESTART)
979
+ }
980
+
981
+ if (!prompt) {
982
+ cliLogger.error('Prompt is required. Use --prompt <prompt>')
983
+ process.exit(EXIT_NO_RESTART)
984
+ }
985
+
986
+ // Get bot token from env var or database
987
+ const envToken = process.env.KIMAKI_BOT_TOKEN
988
+ let botToken: string | undefined
989
+ let appId: string | undefined = optionAppId
990
+
991
+ if (envToken) {
992
+ botToken = envToken
993
+ if (!appId) {
994
+ // Try to get app_id from database if available (optional in CI)
995
+ try {
996
+ const db = getDatabase()
997
+ const botRow = db
998
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
999
+ .get() as { app_id: string } | undefined
1000
+ appId = botRow?.app_id
1001
+ } catch {
1002
+ // Database might not exist in CI, that's ok
1003
+ }
1004
+ }
1005
+ } else {
1006
+ // Fall back to database
1007
+ try {
1008
+ const db = getDatabase()
1009
+ const botRow = db
1010
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1011
+ .get() as { app_id: string; token: string } | undefined
1012
+
1013
+ if (botRow) {
1014
+ botToken = botRow.token
1015
+ appId = appId || botRow.app_id
1016
+ }
1017
+ } catch (e) {
1018
+ // Database error - will fall through to the check below
1019
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
1020
+ }
1021
+ }
1022
+
1023
+ if (!botToken) {
1024
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
1025
+ process.exit(EXIT_NO_RESTART)
1026
+ }
1027
+
1028
+ const s = spinner()
1029
+ s.start('Fetching channel info...')
1030
+
1031
+ // Get channel info to extract directory from topic
1032
+ const channelResponse = await fetch(
1033
+ `https://discord.com/api/v10/channels/${channelId}`,
1034
+ {
1035
+ headers: {
1036
+ 'Authorization': `Bot ${botToken}`,
1037
+ },
1038
+ }
1039
+ )
1040
+
1041
+ if (!channelResponse.ok) {
1042
+ const error = await channelResponse.text()
1043
+ s.stop('Failed to fetch channel')
1044
+ throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
1045
+ }
1046
+
1047
+ const channelData = await channelResponse.json() as {
1048
+ id: string
1049
+ name: string
1050
+ topic?: string
1051
+ guild_id: string
1052
+ }
1053
+
1054
+ if (!channelData.topic) {
1055
+ s.stop('Channel has no topic')
1056
+ throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
1057
+ }
1058
+
1059
+ const extracted = extractTagsArrays({
1060
+ xml: channelData.topic,
1061
+ tags: ['kimaki.directory', 'kimaki.app'],
1062
+ })
1063
+
1064
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
1065
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
1066
+
1067
+ if (!projectDirectory) {
1068
+ s.stop('No kimaki.directory tag found')
1069
+ throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
1070
+ }
1071
+
1072
+ // Verify app ID matches if both are present
1073
+ if (channelAppId && appId && channelAppId !== appId) {
1074
+ s.stop('Channel belongs to different bot')
1075
+ throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
1076
+ }
1077
+
1078
+ s.message('Creating starter message...')
1079
+
1080
+ // Create starter message with magic prefix
1081
+ // The full prompt goes in the message so the bot can read it
1082
+ const starterMessageResponse = await fetch(
1083
+ `https://discord.com/api/v10/channels/${channelId}/messages`,
1084
+ {
1085
+ method: 'POST',
1086
+ headers: {
1087
+ 'Authorization': `Bot ${botToken}`,
1088
+ 'Content-Type': 'application/json',
1089
+ },
1090
+ body: JSON.stringify({
1091
+ content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1092
+ }),
1093
+ }
1094
+ )
1095
+
1096
+ if (!starterMessageResponse.ok) {
1097
+ const error = await starterMessageResponse.text()
1098
+ s.stop('Failed to create message')
1099
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
1100
+ }
1101
+
1102
+ const starterMessage = await starterMessageResponse.json() as { id: string }
1103
+
1104
+ s.message('Creating thread...')
1105
+
1106
+ // Create thread from the message
1107
+ const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt)
1108
+ const threadResponse = await fetch(
1109
+ `https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`,
1110
+ {
1111
+ method: 'POST',
1112
+ headers: {
1113
+ 'Authorization': `Bot ${botToken}`,
1114
+ 'Content-Type': 'application/json',
1115
+ },
1116
+ body: JSON.stringify({
1117
+ name: threadName.slice(0, 100),
1118
+ auto_archive_duration: 1440, // 1 day
1119
+ }),
1120
+ }
1121
+ )
1122
+
1123
+ if (!threadResponse.ok) {
1124
+ const error = await threadResponse.text()
1125
+ s.stop('Failed to create thread')
1126
+ throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
1127
+ }
1128
+
1129
+ const threadData = await threadResponse.json() as { id: string; name: string }
1130
+
1131
+ s.stop('Thread created!')
1132
+
1133
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
1134
+
1135
+ note(
1136
+ `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
1137
+ '✅ Thread Created',
1138
+ )
1139
+
1140
+ console.log(threadUrl)
1141
+
1142
+ process.exit(0)
1143
+ } catch (error) {
1144
+ cliLogger.error(
1145
+ 'Error:',
1146
+ error instanceof Error ? error.message : String(error),
1147
+ )
1148
+ process.exit(EXIT_NO_RESTART)
1149
+ }
1150
+ })
1151
+
961
1152
 
962
1153
  cli.help()
963
1154
  cli.parse()
@@ -412,6 +412,99 @@ export async function startDiscordBot({
412
412
  }
413
413
  })
414
414
 
415
+ // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
416
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
417
+
418
+ // Handle bot-initiated threads created by `kimaki start-session`
419
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
420
+ try {
421
+ if (!newlyCreated) {
422
+ return
423
+ }
424
+
425
+ // Only handle threads in text channels
426
+ const parent = thread.parent as TextChannel | null
427
+ if (!parent || parent.type !== ChannelType.GuildText) {
428
+ return
429
+ }
430
+
431
+ // Get the starter message to check for magic prefix
432
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null)
433
+ if (!starterMessage) {
434
+ discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
435
+ return
436
+ }
437
+
438
+ // Only handle messages from this bot with the magic prefix
439
+ if (starterMessage.author.id !== discordClient.user?.id) {
440
+ return
441
+ }
442
+
443
+ if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
444
+ return
445
+ }
446
+
447
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
448
+
449
+ // Extract the prompt (everything after the prefix)
450
+ const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
451
+ if (!prompt) {
452
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
453
+ return
454
+ }
455
+
456
+ // Extract directory from parent channel topic
457
+ if (!parent.topic) {
458
+ discordLogger.log(`[BOT_SESSION] Parent channel has no topic`)
459
+ return
460
+ }
461
+
462
+ const extracted = extractTagsArrays({
463
+ xml: parent.topic,
464
+ tags: ['kimaki.directory', 'kimaki.app'],
465
+ })
466
+
467
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
468
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
469
+
470
+ if (!projectDirectory) {
471
+ discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`)
472
+ return
473
+ }
474
+
475
+ if (channelAppId && channelAppId !== currentAppId) {
476
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
477
+ return
478
+ }
479
+
480
+ if (!fs.existsSync(projectDirectory)) {
481
+ discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`)
482
+ await thread.send({
483
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
484
+ flags: SILENT_MESSAGE_FLAGS,
485
+ })
486
+ return
487
+ }
488
+
489
+ discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`)
490
+
491
+ await handleOpencodeSession({
492
+ prompt,
493
+ thread,
494
+ projectDirectory,
495
+ channelId: parent.id,
496
+ })
497
+ } catch (error) {
498
+ voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error)
499
+ try {
500
+ const errMsg = error instanceof Error ? error.message : String(error)
501
+ await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
502
+ } catch {
503
+ // Ignore send errors
504
+ }
505
+ }
506
+ })
507
+
415
508
  await discordClient.login(token)
416
509
 
417
510
  const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
@@ -690,7 +690,7 @@ export async function handleOpencodeSession({
690
690
  path: { id: session.id },
691
691
  body: {
692
692
  parts,
693
- system: getOpencodeSystemMessage({ sessionId: session.id }),
693
+ system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
694
694
  model: modelParam,
695
695
  agent: agentPreference,
696
696
  },
@@ -2,13 +2,13 @@
2
2
  // Creates the system message injected into every OpenCode session,
3
3
  // including Discord-specific formatting rules, diff commands, and permissions info.
4
4
 
5
- export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
5
+ export function getOpencodeSystemMessage({ sessionId, channelId }: { sessionId: string; channelId?: string }) {
6
6
  return `
7
7
  The user is reading your messages from inside Discord, via kimaki.xyz
8
8
 
9
9
  The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
10
10
 
11
- Your current OpenCode session ID is: ${sessionId}
11
+ Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
12
12
 
13
13
  ## permissions
14
14
 
@@ -23,7 +23,15 @@ Only users with these Discord permissions can send messages to the bot:
23
23
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
24
24
 
25
25
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
26
+ ${channelId ? `
27
+ ## starting new sessions from CLI
26
28
 
29
+ To start a new thread/session in this channel programmatically, run:
30
+
31
+ npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
32
+
33
+ This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
34
+ ` : ''}
27
35
  ## showing diffs
28
36
 
29
37
  IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.