kimaki 0.4.47 → 0.4.48

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
@@ -14,7 +14,7 @@ import { createLogger, LogPrefix } from './logger.js';
14
14
  import { uploadFilesToDiscord } from './discord-utils.js';
15
15
  import { spawn, spawnSync, execSync } from 'node:child_process';
16
16
  import http from 'node:http';
17
- import { setDataDir, getDataDir, getLockPort } from './config.js';
17
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js';
18
18
  import { sanitizeAgentName } from './commands/agent.js';
19
19
  const cliLogger = createLogger(LogPrefix.CLI);
20
20
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
@@ -106,7 +106,8 @@ async function checkSingleInstance() {
106
106
  });
107
107
  }
108
108
  }
109
- catch {
109
+ catch (error) {
110
+ cliLogger.debug('Lock port check failed:', error instanceof Error ? error.message : String(error));
110
111
  cliLogger.debug('No other kimaki instance detected on lock port');
111
112
  }
112
113
  }
@@ -399,11 +400,17 @@ async function backgroundInit({ currentDir, token, appId, }) {
399
400
  getClient()
400
401
  .command.list({ query: { directory: currentDir } })
401
402
  .then((r) => r.data || [])
402
- .catch(() => []),
403
+ .catch((error) => {
404
+ cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.message : String(error));
405
+ return [];
406
+ }),
403
407
  getClient()
404
408
  .app.agents({ query: { directory: currentDir } })
405
409
  .then((r) => r.data || [])
406
- .catch(() => []),
410
+ .catch((error) => {
411
+ cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.message : String(error));
412
+ return [];
413
+ }),
407
414
  ]);
408
415
  await registerCommands({ token, appId, userCommands, agents });
409
416
  cliLogger.log('Slash commands registered!');
@@ -448,7 +455,8 @@ async function run({ restart, addChannels, useWorktrees }) {
448
455
  fs.accessSync(p, fs.constants.F_OK);
449
456
  return true;
450
457
  }
451
- catch {
458
+ catch (error) {
459
+ cliLogger.debug(`OpenCode path not found at ${p}:`, error instanceof Error ? error.message : String(error));
452
460
  return false;
453
461
  }
454
462
  });
@@ -676,11 +684,17 @@ async function run({ restart, addChannels, useWorktrees }) {
676
684
  getClient()
677
685
  .command.list({ query: { directory: currentDir } })
678
686
  .then((r) => r.data || [])
679
- .catch(() => []),
687
+ .catch((error) => {
688
+ cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.message : String(error));
689
+ return [];
690
+ }),
680
691
  getClient()
681
692
  .app.agents({ query: { directory: currentDir } })
682
693
  .then((r) => r.data || [])
683
- .catch(() => []),
694
+ .catch((error) => {
695
+ cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.message : String(error));
696
+ return [];
697
+ }),
684
698
  ]);
685
699
  s.stop(`Found ${projects.length} OpenCode project(s)`);
686
700
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
@@ -791,6 +805,7 @@ cli
791
805
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
792
806
  .option('--install-url', 'Print the bot install URL and exit')
793
807
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
808
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
794
809
  .action(async (options) => {
795
810
  try {
796
811
  // Set data directory early, before any database access
@@ -798,6 +813,14 @@ cli
798
813
  setDataDir(options.dataDir);
799
814
  cliLogger.log(`Using data directory: ${getDataDir()}`);
800
815
  }
816
+ if (options.verbosity) {
817
+ if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
818
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`);
819
+ process.exit(EXIT_NO_RESTART);
820
+ }
821
+ setDefaultVerbosity(options.verbosity);
822
+ cliLogger.log(`Default verbosity: ${options.verbosity}`);
823
+ }
801
824
  if (options.installUrl) {
802
825
  const db = getDatabase();
803
826
  const existingBot = db
@@ -920,8 +943,8 @@ cli
920
943
  .get();
921
944
  appId = botRow?.app_id;
922
945
  }
923
- catch {
924
- // Database might not exist in CI, that's ok
946
+ catch (error) {
947
+ cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
925
948
  }
926
949
  }
927
950
  }
@@ -1017,8 +1040,8 @@ cli
1017
1040
  return ch.guild;
1018
1041
  }
1019
1042
  }
1020
- catch {
1021
- // Channel might be deleted, continue
1043
+ catch (error) {
1044
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
1022
1045
  }
1023
1046
  }
1024
1047
  // Fall back to first guild the bot is in
@@ -1199,8 +1222,8 @@ cli
1199
1222
  .get();
1200
1223
  appId = botRow?.app_id;
1201
1224
  }
1202
- catch {
1203
- // Database might not exist in CI
1225
+ catch (error) {
1226
+ cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
1204
1227
  }
1205
1228
  }
1206
1229
  }
@@ -1270,8 +1293,8 @@ cli
1270
1293
  throw new Error('Channel has no guild');
1271
1294
  }
1272
1295
  }
1273
- catch {
1274
- // Channel might be deleted, fall back to first guild
1296
+ catch (error) {
1297
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
1275
1298
  const firstGuild = client.guilds.cache.first();
1276
1299
  if (!firstGuild) {
1277
1300
  s.stop('No guild found');
@@ -1310,13 +1333,13 @@ cli
1310
1333
  process.exit(0);
1311
1334
  }
1312
1335
  }
1313
- catch {
1314
- // Channel might be deleted, continue checking
1336
+ catch (error) {
1337
+ cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.message : String(error));
1315
1338
  }
1316
1339
  }
1317
1340
  }
1318
- catch {
1319
- // Database might not exist, continue to create
1341
+ catch (error) {
1342
+ cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.message : String(error));
1320
1343
  }
1321
1344
  s.message(`Creating channels in ${guild.name}...`);
1322
1345
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
@@ -54,6 +54,7 @@ export async function handleAbortCommand({ command }) {
54
54
  const sessionId = row.session_id;
55
55
  const existingController = abortControllers.get(sessionId);
56
56
  if (existingController) {
57
+ logger.log(`[ABORT] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - user ran /abort command`);
57
58
  existingController.abort(new Error('User requested abort'));
58
59
  abortControllers.delete(sessionId);
59
60
  }
@@ -67,6 +68,7 @@ export async function handleAbortCommand({ command }) {
67
68
  return;
68
69
  }
69
70
  try {
71
+ logger.log(`[ABORT-API] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - sending API abort from /abort command`);
70
72
  await getClient().session.abort({
71
73
  path: { id: sessionId },
72
74
  });
@@ -1,13 +1,51 @@
1
1
  // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
+ // Also exports createNewProject() for reuse during onboarding (welcome channel creation).
2
3
  import { ChannelType } from 'discord.js';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
6
+ import { execSync } from 'node:child_process';
5
7
  import { getProjectsDir } from '../config.js';
6
8
  import { createProjectChannels } from '../channel-management.js';
7
9
  import { handleOpencodeSession } from '../session-handler.js';
8
10
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
9
11
  import { createLogger, LogPrefix } from '../logger.js';
10
12
  const logger = createLogger(LogPrefix.CREATE_PROJECT);
13
+ /**
14
+ * Core project creation logic: creates directory, inits git, creates Discord channels.
15
+ * Reused by the slash command handler and by onboarding (welcome channel).
16
+ * Returns null if the project directory already exists.
17
+ */
18
+ export async function createNewProject({ guild, projectName, appId, botName, }) {
19
+ const sanitizedName = projectName
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]/g, '-')
22
+ .replace(/-+/g, '-')
23
+ .replace(/^-|-$/g, '')
24
+ .slice(0, 100);
25
+ if (!sanitizedName) {
26
+ return null;
27
+ }
28
+ const projectsDir = getProjectsDir();
29
+ const projectDirectory = path.join(projectsDir, sanitizedName);
30
+ if (!fs.existsSync(projectsDir)) {
31
+ fs.mkdirSync(projectsDir, { recursive: true });
32
+ logger.log(`Created projects directory: ${projectsDir}`);
33
+ }
34
+ if (fs.existsSync(projectDirectory)) {
35
+ return null;
36
+ }
37
+ fs.mkdirSync(projectDirectory, { recursive: true });
38
+ logger.log(`Created project directory: ${projectDirectory}`);
39
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
40
+ logger.log(`Initialized git in: ${projectDirectory}`);
41
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
42
+ guild,
43
+ projectDirectory,
44
+ appId,
45
+ botName,
46
+ });
47
+ return { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName };
48
+ }
11
49
  export async function handleCreateNewProjectCommand({ command, appId, }) {
12
50
  await command.deferReply({ ephemeral: false });
13
51
  const projectName = command.options.getString('name', true);
@@ -21,37 +59,29 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
21
59
  await command.editReply('This command can only be used in a text channel');
22
60
  return;
23
61
  }
24
- const sanitizedName = projectName
25
- .toLowerCase()
26
- .replace(/[^a-z0-9-]/g, '-')
27
- .replace(/-+/g, '-')
28
- .replace(/^-|-$/g, '')
29
- .slice(0, 100);
30
- if (!sanitizedName) {
31
- await command.editReply('Invalid project name');
32
- return;
33
- }
34
- const projectsDir = getProjectsDir();
35
- const projectDirectory = path.join(projectsDir, sanitizedName);
36
62
  try {
37
- if (!fs.existsSync(projectsDir)) {
38
- fs.mkdirSync(projectsDir, { recursive: true });
39
- logger.log(`Created projects directory: ${projectsDir}`);
40
- }
41
- if (fs.existsSync(projectDirectory)) {
42
- await command.editReply(`Project directory already exists: ${projectDirectory}`);
43
- return;
44
- }
45
- fs.mkdirSync(projectDirectory, { recursive: true });
46
- logger.log(`Created project directory: ${projectDirectory}`);
47
- const { execSync } = await import('node:child_process');
48
- execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
49
- logger.log(`Initialized git in: ${projectDirectory}`);
50
- const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
63
+ const result = await createNewProject({
51
64
  guild,
52
- projectDirectory,
65
+ projectName,
53
66
  appId,
67
+ botName: command.client.user?.username,
54
68
  });
69
+ if (!result) {
70
+ const sanitizedName = projectName
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9-]/g, '-')
73
+ .replace(/-+/g, '-')
74
+ .replace(/^-|-$/g, '')
75
+ .slice(0, 100);
76
+ if (!sanitizedName) {
77
+ await command.editReply('Invalid project name');
78
+ return;
79
+ }
80
+ const projectDirectory = path.join(getProjectsDir(), sanitizedName);
81
+ await command.editReply(`Project directory already exists: ${projectDirectory}`);
82
+ return;
83
+ }
84
+ const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result;
55
85
  const textChannel = (await guild.channels.fetch(textChannelId));
56
86
  await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n_Starting session..._`);
57
87
  const starterMessage = await textChannel.send({
@@ -38,7 +38,8 @@ async function isDetachedHead(worktreeDir) {
38
38
  await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
39
39
  return false;
40
40
  }
41
- catch {
41
+ catch (error) {
42
+ logger.debug(`Failed to resolve HEAD for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
42
43
  return true;
43
44
  }
44
45
  }
@@ -50,7 +51,8 @@ async function getCurrentBranch(worktreeDir) {
50
51
  const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
51
52
  return stdout.trim() || null;
52
53
  }
53
- catch {
54
+ catch (error) {
55
+ logger.debug(`Failed to get current branch for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
54
56
  return null;
55
57
  }
56
58
  }
@@ -89,7 +91,8 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
89
91
  const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
90
92
  defaultBranch = stdout.trim() || 'main';
91
93
  }
92
- catch {
94
+ catch (error) {
95
+ logger.warn(`Failed to detect default branch for ${mainRepoDir}, falling back to main:`, error instanceof Error ? error.message : String(error));
93
96
  defaultBranch = 'main';
94
97
  }
95
98
  // 3. Determine if we're on a branch or detached HEAD
@@ -115,11 +118,17 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
115
118
  }
116
119
  catch (e) {
117
120
  // If merge fails (conflicts), abort and report
118
- await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => { });
121
+ await execAsync(`git -C "${worktreeDir}" merge --abort`).catch((error) => {
122
+ logger.warn(`Failed to abort merge in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
123
+ });
119
124
  // Clean up temp branch if we created one
120
125
  if (tempBranch) {
121
- await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => { });
122
- await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => { });
126
+ await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch((error) => {
127
+ logger.warn(`Failed to detach HEAD after merge conflict in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
128
+ });
129
+ await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch((error) => {
130
+ logger.warn(`Failed to delete temp branch ${tempBranch} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
131
+ });
123
132
  }
124
133
  throw new Error(`Merge conflict - resolve manually in worktree then retry`);
125
134
  }
@@ -133,10 +142,14 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
133
142
  await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
134
143
  // 7. Delete the merged branch (temp or original)
135
144
  logger.log(`Deleting merged branch ${branchToMerge}`);
136
- await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => { });
145
+ await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch((error) => {
146
+ logger.warn(`Failed to delete merged branch ${branchToMerge} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
147
+ });
137
148
  // Also delete the original worktree branch if different from what we merged
138
149
  if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
139
- await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => { });
150
+ await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch((error) => {
151
+ logger.warn(`Failed to delete worktree branch ${worktreeInfo.worktree_name} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
152
+ });
140
153
  }
141
154
  // 8. Remove worktree prefix from thread title (fire and forget with timeout)
142
155
  void removeWorktreePrefixFromTitle(thread);
@@ -13,7 +13,7 @@ export const pendingPermissionContexts = new Map();
13
13
  * Show permission dropdown for a permission request.
14
14
  * Returns the message ID and context hash for tracking.
15
15
  */
16
- export async function showPermissionDropdown({ thread, permission, directory, }) {
16
+ export async function showPermissionDropdown({ thread, permission, directory, subtaskLabel, }) {
17
17
  const contextHash = crypto.randomBytes(8).toString('hex');
18
18
  const context = {
19
19
  permission,
@@ -47,8 +47,10 @@ export async function showPermissionDropdown({ thread, permission, directory, })
47
47
  .setPlaceholder('Choose an action')
48
48
  .addOptions(options);
49
49
  const actionRow = new ActionRowBuilder().addComponents(selectMenu);
50
+ const subtaskLine = subtaskLabel ? `**From:** \`${subtaskLabel}\`\n` : '';
50
51
  const permissionMessage = await thread.send({
51
52
  content: `⚠️ **Permission Required**\n\n` +
53
+ subtaskLine +
52
54
  `**Type:** \`${permission.permission}\`\n` +
53
55
  (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
54
56
  components: [actionRow],
@@ -105,7 +105,10 @@ async function handleAgentAutocomplete({ interaction, appId }) {
105
105
  return;
106
106
  }
107
107
  const agents = agentsResponse.data
108
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
108
+ .filter((a) => {
109
+ const hidden = a.hidden;
110
+ return (a.mode === 'primary' || a.mode === 'all') && !hidden;
111
+ })
109
112
  .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
110
113
  .slice(0, 25);
111
114
  const choices = agents.map((agent) => ({
@@ -8,7 +8,7 @@ import { createLogger, LogPrefix } from '../logger.js';
8
8
  const verbosityLogger = createLogger(LogPrefix.VERBOSITY);
9
9
  /**
10
10
  * Handle the /verbosity slash command.
11
- * Sets output verbosity for the channel (applies to new sessions).
11
+ * Sets output verbosity for the channel (applies immediately, even mid-session).
12
12
  */
13
13
  export async function handleVerbosityCommand({ command, appId, }) {
14
14
  verbosityLogger.log('[VERBOSITY] Command called');
@@ -36,7 +36,7 @@ export async function handleVerbosityCommand({ command, appId, }) {
36
36
  const currentLevel = getChannelVerbosity(channelId);
37
37
  if (currentLevel === level) {
38
38
  await command.reply({
39
- content: `Verbosity is already set to **${level}**.`,
39
+ content: `Verbosity is already set to **${level}** for this channel.`,
40
40
  ephemeral: true,
41
41
  });
42
42
  return;
@@ -47,7 +47,7 @@ export async function handleVerbosityCommand({ command, appId, }) {
47
47
  ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
48
48
  : 'All output will be shown, including tool executions and status messages.';
49
49
  await command.reply({
50
- content: `Verbosity set to **${level}**.\n${description}\nThis applies to all new sessions in this channel.`,
50
+ content: `Verbosity set to **${level}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
51
51
  ephemeral: true,
52
52
  });
53
53
  }
package/dist/config.js CHANGED
@@ -35,6 +35,13 @@ export function setDataDir(dir) {
35
35
  export function getProjectsDir() {
36
36
  return path.join(getDataDir(), 'projects');
37
37
  }
38
+ let defaultVerbosity = 'tools-and-text';
39
+ export function getDefaultVerbosity() {
40
+ return defaultVerbosity;
41
+ }
42
+ export function setDefaultVerbosity(level) {
43
+ defaultVerbosity = level;
44
+ }
38
45
  const DEFAULT_LOCK_PORT = 29988;
39
46
  /**
40
47
  * Derive a lock port from the data directory path.
package/dist/database.js CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import * as errore from 'errore';
8
8
  import { createLogger, LogPrefix } from './logger.js';
9
- import { getDataDir } from './config.js';
9
+ import { getDataDir, getDefaultVerbosity } from './config.js';
10
10
  const dbLogger = createLogger(LogPrefix.DB);
11
11
  let db = null;
12
12
  export function getDatabase() {
@@ -58,8 +58,8 @@ export function getDatabase() {
58
58
  try {
59
59
  db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`);
60
60
  }
61
- catch {
62
- // Column already exists, ignore
61
+ catch (error) {
62
+ dbLogger.debug('Failed to add app_id column to channel_directories (likely exists):', error instanceof Error ? error.message : String(error));
63
63
  }
64
64
  // Table for threads that should auto-start a session (created by CLI without --notify-only)
65
65
  db.exec(`
@@ -281,14 +281,17 @@ export function runVerbosityMigrations(database) {
281
281
  }
282
282
  /**
283
283
  * Get the verbosity setting for a channel.
284
- * @returns 'tools-and-text' (default) or 'text-only'
284
+ * Falls back to the global default set via --verbosity CLI flag if no per-channel override exists.
285
285
  */
286
286
  export function getChannelVerbosity(channelId) {
287
287
  const db = getDatabase();
288
288
  const row = db
289
289
  .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
290
290
  .get(channelId);
291
- return row?.verbosity || 'tools-and-text';
291
+ if (row?.verbosity) {
292
+ return row.verbosity;
293
+ }
294
+ return getDefaultVerbosity();
292
295
  }
293
296
  /**
294
297
  * Set the verbosity setting for a channel.
@@ -30,6 +30,9 @@ import { setGlobalDispatcher, Agent } from 'undici';
30
30
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
31
31
  const discordLogger = createLogger(LogPrefix.DISCORD);
32
32
  const voiceLogger = createLogger(LogPrefix.VOICE);
33
+ function prefixWithDiscordUser({ username, prompt }) {
34
+ return `<discord-user name="${username}" />\n${prompt}`;
35
+ }
33
36
  export async function createDiscordClient() {
34
37
  return new Client({
35
38
  intents: [
@@ -93,6 +96,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
93
96
  if (message.author?.bot) {
94
97
  return;
95
98
  }
99
+ // Ignore messages that start with a mention of another user (not the bot).
100
+ // These are likely users talking to each other, not the bot.
101
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
102
+ if (leadingMentionMatch) {
103
+ const mentionedUserId = leadingMentionMatch[1];
104
+ if (mentionedUserId !== discordClient.user?.id) {
105
+ return;
106
+ }
107
+ }
96
108
  if (message.partial) {
97
109
  discordLogger.log(`Fetching partial message ${message.id}`);
98
110
  const fetched = await errore.tryAsync({
@@ -184,12 +196,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
184
196
  }
185
197
  // Include starter message as context for the session
186
198
  let prompt = message.content;
187
- const starterMessage = await thread.fetchStarterMessage().catch(() => null);
199
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
200
+ discordLogger.warn(`[SESSION] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.message : String(error));
201
+ return null;
202
+ });
188
203
  if (starterMessage?.content && starterMessage.content !== message.content) {
189
204
  prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
190
205
  }
191
206
  await handleOpencodeSession({
192
- prompt,
207
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt }),
193
208
  thread,
194
209
  projectDirectory,
195
210
  channelId: parent?.id || '',
@@ -259,7 +274,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
259
274
  ? `${messageContent}\n\n${textAttachmentsContent}`
260
275
  : messageContent;
261
276
  await handleOpencodeSession({
262
- prompt: promptWithAttachments,
277
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
263
278
  thread,
264
279
  projectDirectory,
265
280
  originalMessage: message,
@@ -379,7 +394,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
379
394
  ? `${messageContent}\n\n${textAttachmentsContent}`
380
395
  : messageContent;
381
396
  await handleOpencodeSession({
382
- prompt: promptWithAttachments,
397
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
383
398
  thread,
384
399
  projectDirectory: sessionDirectory,
385
400
  originalMessage: message,
@@ -397,8 +412,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
397
412
  const errMsg = error instanceof Error ? error.message : String(error);
398
413
  await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
399
414
  }
400
- catch {
401
- voiceLogger.error('Discord handler error (fallback):', error);
415
+ catch (sendError) {
416
+ voiceLogger.error('Discord handler error (fallback):', sendError instanceof Error ? sendError.message : String(sendError));
402
417
  }
403
418
  }
404
419
  });
@@ -416,7 +431,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
416
431
  return;
417
432
  }
418
433
  // Get the starter message to check for auto-start marker
419
- const starterMessage = await thread.fetchStarterMessage().catch(() => null);
434
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
435
+ discordLogger.warn(`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.message : String(error));
436
+ return null;
437
+ });
420
438
  if (!starterMessage) {
421
439
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
422
440
  return;
@@ -466,8 +484,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
466
484
  const errMsg = error instanceof Error ? error.message : String(error);
467
485
  await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
468
486
  }
469
- catch {
470
- // Ignore send errors
487
+ catch (sendError) {
488
+ voiceLogger.error('[BOT_SESSION] Failed to send error message:', sendError instanceof Error ? sendError.message : String(sendError));
471
489
  }
472
490
  }
473
491
  });