kimaki 0.4.28 → 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
@@ -4,7 +4,7 @@
4
4
  // project channel creation, and launching the bot with opencode integration.
5
5
  import { cac } from 'cac';
6
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
7
- import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
7
+ import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
8
  import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
9
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
10
  import path from 'node:path';
@@ -12,10 +12,11 @@ import fs from 'node:fs';
12
12
  import { createLogger } from './logger.js';
13
13
  import { spawn, spawnSync, execSync } from 'node:child_process';
14
14
  import http from 'node:http';
15
+ import { setDataDir, getDataDir, getLockPort } from './config.js';
16
+ import { extractTagsArrays } from './xml.js';
15
17
  const cliLogger = createLogger('CLI');
16
18
  const cli = cac('kimaki');
17
19
  process.title = 'kimaki';
18
- const LOCK_PORT = 29988;
19
20
  async function killProcessOnPort(port) {
20
21
  const isWindows = process.platform === 'win32';
21
22
  const myPid = process.pid;
@@ -58,13 +59,14 @@ async function killProcessOnPort(port) {
58
59
  return false;
59
60
  }
60
61
  async function checkSingleInstance() {
62
+ const lockPort = getLockPort();
61
63
  try {
62
- const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
64
+ const response = await fetch(`http://127.0.0.1:${lockPort}`, {
63
65
  signal: AbortSignal.timeout(1000),
64
66
  });
65
67
  if (response.ok) {
66
- cliLogger.log('Another kimaki instance detected');
67
- await killProcessOnPort(LOCK_PORT);
68
+ cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
69
+ await killProcessOnPort(lockPort);
68
70
  // Wait a moment for port to be released
69
71
  await new Promise((resolve) => { setTimeout(resolve, 500); });
70
72
  }
@@ -74,22 +76,24 @@ async function checkSingleInstance() {
74
76
  }
75
77
  }
76
78
  async function startLockServer() {
79
+ const lockPort = getLockPort();
77
80
  return new Promise((resolve, reject) => {
78
81
  const server = http.createServer((req, res) => {
79
82
  res.writeHead(200);
80
83
  res.end('kimaki');
81
84
  });
82
- server.listen(LOCK_PORT, '127.0.0.1');
85
+ server.listen(lockPort, '127.0.0.1');
83
86
  server.once('listening', () => {
87
+ cliLogger.debug(`Lock server started on port ${lockPort}`);
84
88
  resolve();
85
89
  });
86
90
  server.on('error', async (err) => {
87
91
  if (err.code === 'EADDRINUSE') {
88
92
  cliLogger.log('Port still in use, retrying...');
89
- await killProcessOnPort(LOCK_PORT);
93
+ await killProcessOnPort(lockPort);
90
94
  await new Promise((r) => { setTimeout(r, 500); });
91
95
  // Retry once
92
- server.listen(LOCK_PORT, '127.0.0.1');
96
+ server.listen(lockPort, '127.0.0.1');
93
97
  }
94
98
  else {
95
99
  reject(err);
@@ -461,7 +465,15 @@ async function run({ restart, addChannels }) {
461
465
  .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
462
466
  .map((ch) => ch.kimakiDirectory)
463
467
  .filter(Boolean));
464
- const availableProjects = deduplicateByKey(projects.filter((project) => !existingDirs.includes(project.worktree)), (x) => x.worktree);
468
+ const availableProjects = deduplicateByKey(projects.filter((project) => {
469
+ if (existingDirs.includes(project.worktree)) {
470
+ return false;
471
+ }
472
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
473
+ return false;
474
+ }
475
+ return true;
476
+ }), (x) => x.worktree);
465
477
  if (availableProjects.length === 0) {
466
478
  note('All OpenCode projects already have Discord channels', 'No New Projects');
467
479
  }
@@ -471,7 +483,7 @@ async function run({ restart, addChannels }) {
471
483
  message: 'Select projects to create Discord channels for:',
472
484
  options: availableProjects.map((project) => ({
473
485
  value: project.id,
474
- label: `${path.basename(project.worktree)} (${project.worktree})`,
486
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
475
487
  })),
476
488
  required: false,
477
489
  });
@@ -585,13 +597,20 @@ cli
585
597
  .command('', 'Set up and run the Kimaki Discord bot')
586
598
  .option('--restart', 'Prompt for new credentials even if saved')
587
599
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
600
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
588
601
  .action(async (options) => {
589
602
  try {
603
+ // Set data directory early, before any database access
604
+ if (options.dataDir) {
605
+ setDataDir(options.dataDir);
606
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
607
+ }
590
608
  await checkSingleInstance();
591
609
  await startLockServer();
592
610
  await run({
593
611
  restart: options.restart,
594
612
  addChannels: options.addChannels,
613
+ dataDir: options.dataDir,
595
614
  });
596
615
  }
597
616
  catch (error) {
@@ -665,5 +684,149 @@ cli
665
684
  process.exit(EXIT_NO_RESTART);
666
685
  }
667
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
+ });
668
831
  cli.help();
669
832
  cli.parse();
@@ -5,6 +5,7 @@ import { getDatabase } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger } from '../logger.js';
8
+ import { abbreviatePath } from '../utils.js';
8
9
  const logger = createLogger('ADD-PROJECT');
9
10
  export async function handleAddProjectCommand({ command, appId, }) {
10
11
  await command.deferReply({ ephemeral: false });
@@ -69,7 +70,15 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
69
70
  .prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
70
71
  .all('text');
71
72
  const existingDirSet = new Set(existingDirs.map((row) => row.directory));
72
- const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
73
+ const availableProjects = projectsResponse.data.filter((project) => {
74
+ if (existingDirSet.has(project.worktree)) {
75
+ return false;
76
+ }
77
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
78
+ return false;
79
+ }
80
+ return true;
81
+ });
73
82
  const projects = availableProjects
74
83
  .filter((project) => {
75
84
  const baseName = path.basename(project.worktree);
@@ -83,7 +92,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
83
92
  })
84
93
  .slice(0, 25)
85
94
  .map((project) => {
86
- const name = `${path.basename(project.worktree)} (${project.worktree})`;
95
+ const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`;
87
96
  return {
88
97
  name: name.length > 100 ? name.slice(0, 99) + '…' : name,
89
98
  value: project.id,
@@ -1,8 +1,8 @@
1
1
  // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
2
  import { ChannelType } from 'discord.js';
3
3
  import fs from 'node:fs';
4
- import os from 'node:os';
5
4
  import path from 'node:path';
5
+ import { getProjectsDir } from '../config.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
7
  import { handleOpencodeSession } from '../session-handler.js';
8
8
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
@@ -31,12 +31,12 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
31
31
  await command.editReply('Invalid project name');
32
32
  return;
33
33
  }
34
- const kimakiDir = path.join(os.homedir(), 'kimaki');
35
- const projectDirectory = path.join(kimakiDir, sanitizedName);
34
+ const projectsDir = getProjectsDir();
35
+ const projectDirectory = path.join(projectsDir, sanitizedName);
36
36
  try {
37
- if (!fs.existsSync(kimakiDir)) {
38
- fs.mkdirSync(kimakiDir, { recursive: true });
39
- logger.log(`Created kimaki directory: ${kimakiDir}`);
37
+ if (!fs.existsSync(projectsDir)) {
38
+ fs.mkdirSync(projectsDir, { recursive: true });
39
+ logger.log(`Created projects directory: ${projectsDir}`);
40
40
  }
41
41
  if (fs.existsSync(projectDirectory)) {
42
42
  await command.editReply(`Project directory already exists: ${projectDirectory}`);
package/dist/config.js ADDED
@@ -0,0 +1,59 @@
1
+ // Runtime configuration for Kimaki bot.
2
+ // Stores data directory path and provides accessors for other modules.
3
+ // Must be initialized before database or other path-dependent modules are used.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
8
+ let dataDir = null;
9
+ /**
10
+ * Get the data directory path.
11
+ * Falls back to ~/.kimaki if not explicitly set.
12
+ */
13
+ export function getDataDir() {
14
+ if (!dataDir) {
15
+ dataDir = DEFAULT_DATA_DIR;
16
+ }
17
+ return dataDir;
18
+ }
19
+ /**
20
+ * Set the data directory path.
21
+ * Creates the directory if it doesn't exist.
22
+ * Must be called before any database or path-dependent operations.
23
+ */
24
+ export function setDataDir(dir) {
25
+ const resolvedDir = path.resolve(dir);
26
+ if (!fs.existsSync(resolvedDir)) {
27
+ fs.mkdirSync(resolvedDir, { recursive: true });
28
+ }
29
+ dataDir = resolvedDir;
30
+ }
31
+ /**
32
+ * Get the projects directory path (for /create-new-project command).
33
+ * Returns <dataDir>/projects
34
+ */
35
+ export function getProjectsDir() {
36
+ return path.join(getDataDir(), 'projects');
37
+ }
38
+ const DEFAULT_LOCK_PORT = 29988;
39
+ /**
40
+ * Derive a lock port from the data directory path.
41
+ * Returns 29988 for the default ~/.kimaki directory (backwards compatible).
42
+ * For custom data dirs, uses a hash to generate a port in the range 30000-39999.
43
+ */
44
+ export function getLockPort() {
45
+ const dir = getDataDir();
46
+ // Use original port for default data dir (backwards compatible)
47
+ if (dir === DEFAULT_DATA_DIR) {
48
+ return DEFAULT_LOCK_PORT;
49
+ }
50
+ // Hash-based port for custom data dirs
51
+ let hash = 0;
52
+ for (let i = 0; i < dir.length; i++) {
53
+ const char = dir.charCodeAt(i);
54
+ hash = ((hash << 5) - hash) + char;
55
+ hash = hash & hash; // Convert to 32bit integer
56
+ }
57
+ // Map to port range 30000-39999
58
+ return 30000 + (Math.abs(hash) % 10000);
59
+ }
package/dist/database.js CHANGED
@@ -1,23 +1,23 @@
1
1
  // SQLite database manager for persistent bot state.
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
- // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
3
+ // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
  import Database from 'better-sqlite3';
5
5
  import fs from 'node:fs';
6
- import os from 'node:os';
7
6
  import path from 'node:path';
8
7
  import { createLogger } from './logger.js';
8
+ import { getDataDir } from './config.js';
9
9
  const dbLogger = createLogger('DB');
10
10
  let db = null;
11
11
  export function getDatabase() {
12
12
  if (!db) {
13
- const kimakiDir = path.join(os.homedir(), '.kimaki');
13
+ const dataDir = getDataDir();
14
14
  try {
15
- fs.mkdirSync(kimakiDir, { recursive: true });
15
+ fs.mkdirSync(dataDir, { recursive: true });
16
16
  }
17
17
  catch (error) {
18
- dbLogger.error('Failed to create ~/.kimaki directory:', error);
18
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, error);
19
19
  }
20
- const dbPath = path.join(kimakiDir, 'discord-sessions.db');
20
+ const dbPath = path.join(dataDir, 'discord-sessions.db');
21
21
  dbLogger.log(`Opening database at: ${dbPath}`);
22
22
  db = new Database(dbPath);
23
23
  db.exec(`
@@ -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/dist/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // General utility functions for the bot.
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
+ import os from 'node:os';
4
5
  import { PermissionsBitField } from 'discord.js';
5
6
  export function generateBotInstallUrl({ clientId, permissions = [
6
7
  PermissionsBitField.Flags.ViewChannel,
@@ -83,3 +84,10 @@ const dtf = new Intl.DateTimeFormat('en-US', {
83
84
  export function formatDateTime(date) {
84
85
  return dtf.format(date);
85
86
  }
87
+ export function abbreviatePath(fullPath) {
88
+ const home = os.homedir();
89
+ if (fullPath.startsWith(home)) {
90
+ return '~' + fullPath.slice(home.length);
91
+ }
92
+ return fullPath;
93
+ }
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.28",
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
@@ -16,7 +16,7 @@ import {
16
16
  multiselect,
17
17
  spinner,
18
18
  } from '@clack/prompts'
19
- import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
19
+ import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js'
20
20
  import {
21
21
  getChannelsWithDescriptions,
22
22
  createDiscordClient,
@@ -45,14 +45,14 @@ import fs from 'node:fs'
45
45
  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
+ import { setDataDir, getDataDir, getLockPort } from './config.js'
49
+ import { extractTagsArrays } from './xml.js'
48
50
 
49
51
  const cliLogger = createLogger('CLI')
50
52
  const cli = cac('kimaki')
51
53
 
52
54
  process.title = 'kimaki'
53
55
 
54
- const LOCK_PORT = 29988
55
-
56
56
  async function killProcessOnPort(port: number): Promise<boolean> {
57
57
  const isWindows = process.platform === 'win32'
58
58
  const myPid = process.pid
@@ -95,13 +95,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
95
95
  }
96
96
 
97
97
  async function checkSingleInstance(): Promise<void> {
98
+ const lockPort = getLockPort()
98
99
  try {
99
- const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
100
+ const response = await fetch(`http://127.0.0.1:${lockPort}`, {
100
101
  signal: AbortSignal.timeout(1000),
101
102
  })
102
103
  if (response.ok) {
103
- cliLogger.log('Another kimaki instance detected')
104
- await killProcessOnPort(LOCK_PORT)
104
+ cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
105
+ await killProcessOnPort(lockPort)
105
106
  // Wait a moment for port to be released
106
107
  await new Promise((resolve) => { setTimeout(resolve, 500) })
107
108
  }
@@ -111,22 +112,24 @@ async function checkSingleInstance(): Promise<void> {
111
112
  }
112
113
 
113
114
  async function startLockServer(): Promise<void> {
115
+ const lockPort = getLockPort()
114
116
  return new Promise((resolve, reject) => {
115
117
  const server = http.createServer((req, res) => {
116
118
  res.writeHead(200)
117
119
  res.end('kimaki')
118
120
  })
119
- server.listen(LOCK_PORT, '127.0.0.1')
121
+ server.listen(lockPort, '127.0.0.1')
120
122
  server.once('listening', () => {
123
+ cliLogger.debug(`Lock server started on port ${lockPort}`)
121
124
  resolve()
122
125
  })
123
126
  server.on('error', async (err: NodeJS.ErrnoException) => {
124
127
  if (err.code === 'EADDRINUSE') {
125
128
  cliLogger.log('Port still in use, retrying...')
126
- await killProcessOnPort(LOCK_PORT)
129
+ await killProcessOnPort(lockPort)
127
130
  await new Promise((r) => { setTimeout(r, 500) })
128
131
  // Retry once
129
- server.listen(LOCK_PORT, '127.0.0.1')
132
+ server.listen(lockPort, '127.0.0.1')
130
133
  } else {
131
134
  reject(err)
132
135
  }
@@ -151,6 +154,7 @@ type Project = {
151
154
  type CliOptions = {
152
155
  restart?: boolean
153
156
  addChannels?: boolean
157
+ dataDir?: string
154
158
  }
155
159
 
156
160
  // Commands to skip when registering user commands (reserved names)
@@ -644,7 +648,15 @@ async function run({ restart, addChannels }: CliOptions) {
644
648
  )
645
649
 
646
650
  const availableProjects = deduplicateByKey(
647
- projects.filter((project) => !existingDirs.includes(project.worktree)),
651
+ projects.filter((project) => {
652
+ if (existingDirs.includes(project.worktree)) {
653
+ return false
654
+ }
655
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
656
+ return false
657
+ }
658
+ return true
659
+ }),
648
660
  (x) => x.worktree,
649
661
  )
650
662
 
@@ -663,7 +675,7 @@ async function run({ restart, addChannels }: CliOptions) {
663
675
  message: 'Select projects to create Discord channels for:',
664
676
  options: availableProjects.map((project) => ({
665
677
  value: project.id,
666
- label: `${path.basename(project.worktree)} (${project.worktree})`,
678
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
667
679
  })),
668
680
  required: false,
669
681
  })
@@ -823,13 +835,24 @@ cli
823
835
  '--add-channels',
824
836
  'Select OpenCode projects to create Discord channels before starting',
825
837
  )
826
- .action(async (options: { restart?: boolean; addChannels?: boolean }) => {
838
+ .option(
839
+ '--data-dir <path>',
840
+ 'Data directory for config and database (default: ~/.kimaki)',
841
+ )
842
+ .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
827
843
  try {
844
+ // Set data directory early, before any database access
845
+ if (options.dataDir) {
846
+ setDataDir(options.dataDir)
847
+ cliLogger.log(`Using data directory: ${getDataDir()}`)
848
+ }
849
+
828
850
  await checkSingleInstance()
829
851
  await startLockServer()
830
852
  await run({
831
853
  restart: options.restart,
832
854
  addChannels: options.addChannels,
855
+ dataDir: options.dataDir,
833
856
  })
834
857
  } catch (error) {
835
858
  cliLogger.error(
@@ -936,6 +959,196 @@ cli
936
959
  })
937
960
 
938
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
+
939
1152
 
940
1153
  cli.help()
941
1154
  cli.parse()
@@ -7,6 +7,7 @@ import { getDatabase } from '../database.js'
7
7
  import { initializeOpencodeForDirectory } from '../opencode.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { createLogger } from '../logger.js'
10
+ import { abbreviatePath } from '../utils.js'
10
11
 
11
12
  const logger = createLogger('ADD-PROJECT')
12
13
 
@@ -107,9 +108,15 @@ export async function handleAddProjectAutocomplete({
107
108
  .all('text') as { directory: string }[]
108
109
  const existingDirSet = new Set(existingDirs.map((row) => row.directory))
109
110
 
110
- const availableProjects = projectsResponse.data.filter(
111
- (project) => !existingDirSet.has(project.worktree),
112
- )
111
+ const availableProjects = projectsResponse.data.filter((project) => {
112
+ if (existingDirSet.has(project.worktree)) {
113
+ return false
114
+ }
115
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
116
+ return false
117
+ }
118
+ return true
119
+ })
113
120
 
114
121
  const projects = availableProjects
115
122
  .filter((project) => {
@@ -124,7 +131,7 @@ export async function handleAddProjectAutocomplete({
124
131
  })
125
132
  .slice(0, 25)
126
133
  .map((project) => {
127
- const name = `${path.basename(project.worktree)} (${project.worktree})`
134
+ const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`
128
135
  return {
129
136
  name: name.length > 100 ? name.slice(0, 99) + '…' : name,
130
137
  value: project.id,
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
5
- import os from 'node:os'
6
5
  import path from 'node:path'
7
6
  import type { CommandContext } from './types.js'
7
+ import { getProjectsDir } from '../config.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { handleOpencodeSession } from '../session-handler.js'
10
10
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
@@ -44,13 +44,13 @@ export async function handleCreateNewProjectCommand({
44
44
  return
45
45
  }
46
46
 
47
- const kimakiDir = path.join(os.homedir(), 'kimaki')
48
- const projectDirectory = path.join(kimakiDir, sanitizedName)
47
+ const projectsDir = getProjectsDir()
48
+ const projectDirectory = path.join(projectsDir, sanitizedName)
49
49
 
50
50
  try {
51
- if (!fs.existsSync(kimakiDir)) {
52
- fs.mkdirSync(kimakiDir, { recursive: true })
53
- logger.log(`Created kimaki directory: ${kimakiDir}`)
51
+ if (!fs.existsSync(projectsDir)) {
52
+ fs.mkdirSync(projectsDir, { recursive: true })
53
+ logger.log(`Created projects directory: ${projectsDir}`)
54
54
  }
55
55
 
56
56
  if (fs.existsSync(projectDirectory)) {
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Runtime configuration for Kimaki bot.
2
+ // Stores data directory path and provides accessors for other modules.
3
+ // Must be initialized before database or other path-dependent modules are used.
4
+
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki')
10
+
11
+ let dataDir: string | null = null
12
+
13
+ /**
14
+ * Get the data directory path.
15
+ * Falls back to ~/.kimaki if not explicitly set.
16
+ */
17
+ export function getDataDir(): string {
18
+ if (!dataDir) {
19
+ dataDir = DEFAULT_DATA_DIR
20
+ }
21
+ return dataDir
22
+ }
23
+
24
+ /**
25
+ * Set the data directory path.
26
+ * Creates the directory if it doesn't exist.
27
+ * Must be called before any database or path-dependent operations.
28
+ */
29
+ export function setDataDir(dir: string): void {
30
+ const resolvedDir = path.resolve(dir)
31
+
32
+ if (!fs.existsSync(resolvedDir)) {
33
+ fs.mkdirSync(resolvedDir, { recursive: true })
34
+ }
35
+
36
+ dataDir = resolvedDir
37
+ }
38
+
39
+ /**
40
+ * Get the projects directory path (for /create-new-project command).
41
+ * Returns <dataDir>/projects
42
+ */
43
+ export function getProjectsDir(): string {
44
+ return path.join(getDataDir(), 'projects')
45
+ }
46
+
47
+ const DEFAULT_LOCK_PORT = 29988
48
+
49
+ /**
50
+ * Derive a lock port from the data directory path.
51
+ * Returns 29988 for the default ~/.kimaki directory (backwards compatible).
52
+ * For custom data dirs, uses a hash to generate a port in the range 30000-39999.
53
+ */
54
+ export function getLockPort(): number {
55
+ const dir = getDataDir()
56
+
57
+ // Use original port for default data dir (backwards compatible)
58
+ if (dir === DEFAULT_DATA_DIR) {
59
+ return DEFAULT_LOCK_PORT
60
+ }
61
+
62
+ // Hash-based port for custom data dirs
63
+ let hash = 0
64
+ for (let i = 0; i < dir.length; i++) {
65
+ const char = dir.charCodeAt(i)
66
+ hash = ((hash << 5) - hash) + char
67
+ hash = hash & hash // Convert to 32bit integer
68
+ }
69
+ // Map to port range 30000-39999
70
+ return 30000 + (Math.abs(hash) % 10000)
71
+ }
package/src/database.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // SQLite database manager for persistent bot state.
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
- // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
3
+ // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
 
5
5
  import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
- import os from 'node:os'
8
7
  import path from 'node:path'
9
8
  import { createLogger } from './logger.js'
9
+ import { getDataDir } from './config.js'
10
10
 
11
11
  const dbLogger = createLogger('DB')
12
12
 
@@ -14,15 +14,15 @@ let db: Database.Database | null = null
14
14
 
15
15
  export function getDatabase(): Database.Database {
16
16
  if (!db) {
17
- const kimakiDir = path.join(os.homedir(), '.kimaki')
17
+ const dataDir = getDataDir()
18
18
 
19
19
  try {
20
- fs.mkdirSync(kimakiDir, { recursive: true })
20
+ fs.mkdirSync(dataDir, { recursive: true })
21
21
  } catch (error) {
22
- dbLogger.error('Failed to create ~/.kimaki directory:', error)
22
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
23
23
  }
24
24
 
25
- const dbPath = path.join(kimakiDir, 'discord-sessions.db')
25
+ const dbPath = path.join(dataDir, 'discord-sessions.db')
26
26
 
27
27
  dbLogger.log(`Opening database at: ${dbPath}`)
28
28
  db = new Database(dbPath)
@@ -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.
package/src/utils.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
4
 
5
+ import os from 'node:os'
5
6
  import { PermissionsBitField } from 'discord.js'
6
7
 
7
8
  type GenerateInstallUrlOptions = {
@@ -116,3 +117,11 @@ const dtf = new Intl.DateTimeFormat('en-US', {
116
117
  export function formatDateTime(date: Date): string {
117
118
  return dtf.format(date)
118
119
  }
120
+
121
+ export function abbreviatePath(fullPath: string): string {
122
+ const home = os.homedir()
123
+ if (fullPath.startsWith(home)) {
124
+ return '~' + fullPath.slice(home.length)
125
+ }
126
+ return fullPath
127
+ }