kimaki 0.4.47 → 0.4.49

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/src/cli.ts CHANGED
@@ -47,11 +47,21 @@ import { createLogger, LogPrefix } from './logger.js'
47
47
  import { uploadFilesToDiscord } from './discord-utils.js'
48
48
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
49
49
  import http from 'node:http'
50
- import { setDataDir, getDataDir, getLockPort } from './config.js'
50
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js'
51
51
  import { sanitizeAgentName } from './commands/agent.js'
52
52
 
53
53
  const cliLogger = createLogger(LogPrefix.CLI)
54
54
 
55
+ // Strip bracketed paste escape sequences from terminal input.
56
+ // iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
57
+ // which can cause validation to fail on macOS. See: https://github.com/remorses/kimaki/issues/18
58
+ function stripBracketedPaste(value: string | undefined): string {
59
+ if (!value) {
60
+ return ''
61
+ }
62
+ return value.replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '').trim()
63
+ }
64
+
55
65
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
56
66
  // Not detached, so it dies automatically with the parent process.
57
67
  function startCaffeinate() {
@@ -145,7 +155,11 @@ async function checkSingleInstance(): Promise<void> {
145
155
  setTimeout(resolve, 500)
146
156
  })
147
157
  }
148
- } catch {
158
+ } catch (error) {
159
+ cliLogger.debug(
160
+ 'Lock port check failed:',
161
+ error instanceof Error ? error.message : String(error),
162
+ )
149
163
  cliLogger.debug('No other kimaki instance detected on lock port')
150
164
  }
151
165
  }
@@ -551,11 +565,23 @@ async function backgroundInit({
551
565
  getClient()
552
566
  .command.list({ query: { directory: currentDir } })
553
567
  .then((r) => r.data || [])
554
- .catch(() => []),
568
+ .catch((error) => {
569
+ cliLogger.warn(
570
+ 'Failed to load user commands during background init:',
571
+ error instanceof Error ? error.message : String(error),
572
+ )
573
+ return []
574
+ }),
555
575
  getClient()
556
576
  .app.agents({ query: { directory: currentDir } })
557
577
  .then((r) => r.data || [])
558
- .catch(() => []),
578
+ .catch((error) => {
579
+ cliLogger.warn(
580
+ 'Failed to load agents during background init:',
581
+ error instanceof Error ? error.message : String(error),
582
+ )
583
+ return []
584
+ }),
559
585
  ])
560
586
 
561
587
  await registerCommands({ token, appId, userCommands, agents })
@@ -613,7 +639,11 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
613
639
  try {
614
640
  fs.accessSync(p, fs.constants.F_OK)
615
641
  return true
616
- } catch {
642
+ } catch (error) {
643
+ cliLogger.debug(
644
+ `OpenCode path not found at ${p}:`,
645
+ error instanceof Error ? error.message : String(error),
646
+ )
617
647
  return false
618
648
  }
619
649
  })
@@ -676,9 +706,13 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
676
706
  message: 'Enter your Discord Application ID:',
677
707
  placeholder: 'e.g., 1234567890123456789',
678
708
  validate(value) {
679
- if (!value) return 'Application ID is required'
680
- if (!/^\d{17,20}$/.test(value))
709
+ const cleaned = stripBracketedPaste(value)
710
+ if (!cleaned) {
711
+ return 'Application ID is required'
712
+ }
713
+ if (!/^\d{17,20}$/.test(cleaned)) {
681
714
  return 'Invalid Application ID format (should be 17-20 digits)'
715
+ }
682
716
  },
683
717
  })
684
718
 
@@ -686,7 +720,7 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
686
720
  cancel('Setup cancelled')
687
721
  process.exit(0)
688
722
  }
689
- appId = appIdInput
723
+ appId = stripBracketedPaste(appIdInput)
690
724
 
691
725
  note(
692
726
  '1. Go to the "Bot" section in the left sidebar\n' +
@@ -717,8 +751,13 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
717
751
  const tokenInput = await password({
718
752
  message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
719
753
  validate(value) {
720
- if (!value) return 'Bot token is required'
721
- if (value.length < 50) return 'Invalid token format (too short)'
754
+ const cleaned = stripBracketedPaste(value)
755
+ if (!cleaned) {
756
+ return 'Bot token is required'
757
+ }
758
+ if (cleaned.length < 50) {
759
+ return 'Invalid token format (too short)'
760
+ }
722
761
  },
723
762
  })
724
763
 
@@ -726,29 +765,34 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
726
765
  cancel('Setup cancelled')
727
766
  process.exit(0)
728
767
  }
729
- token = tokenInput
768
+ token = stripBracketedPaste(tokenInput)
730
769
 
731
770
  note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`)
732
771
 
733
- const geminiApiKey = await password({
772
+ const geminiApiKeyInput = await password({
734
773
  message:
735
774
  'Enter your Gemini API Key for voice channels and audio transcription (optional, press Enter to skip):',
736
775
  validate(value) {
737
- if (value && value.length < 10) return 'Invalid API key format'
776
+ const cleaned = stripBracketedPaste(value)
777
+ if (cleaned && cleaned.length < 10) {
778
+ return 'Invalid API key format'
779
+ }
738
780
  return undefined
739
781
  },
740
782
  })
741
783
 
742
- if (isCancel(geminiApiKey)) {
784
+ if (isCancel(geminiApiKeyInput)) {
743
785
  cancel('Setup cancelled')
744
786
  process.exit(0)
745
787
  }
746
788
 
789
+ const geminiApiKey = stripBracketedPaste(geminiApiKeyInput) || null
790
+
747
791
  // Store API key in database
748
792
  if (geminiApiKey) {
749
793
  db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(
750
794
  appId,
751
- geminiApiKey || null,
795
+ geminiApiKey,
752
796
  )
753
797
  note('API key saved successfully', 'API Key Stored')
754
798
  }
@@ -914,11 +958,23 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
914
958
  getClient()
915
959
  .command.list({ query: { directory: currentDir } })
916
960
  .then((r) => r.data || [])
917
- .catch(() => []),
961
+ .catch((error) => {
962
+ cliLogger.warn(
963
+ 'Failed to load user commands during setup:',
964
+ error instanceof Error ? error.message : String(error),
965
+ )
966
+ return []
967
+ }),
918
968
  getClient()
919
969
  .app.agents({ query: { directory: currentDir } })
920
970
  .then((r) => r.data || [])
921
- .catch(() => []),
971
+ .catch((error) => {
972
+ cliLogger.warn(
973
+ 'Failed to load agents during setup:',
974
+ error instanceof Error ? error.message : String(error),
975
+ )
976
+ return []
977
+ }),
922
978
  ])
923
979
 
924
980
  s.stop(`Found ${projects.length} OpenCode project(s)`)
@@ -1066,6 +1122,7 @@ cli
1066
1122
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1067
1123
  .option('--install-url', 'Print the bot install URL and exit')
1068
1124
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1125
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
1069
1126
  .action(
1070
1127
  async (options: {
1071
1128
  restart?: boolean
@@ -1073,6 +1130,7 @@ cli
1073
1130
  dataDir?: string
1074
1131
  installUrl?: boolean
1075
1132
  useWorktrees?: boolean
1133
+ verbosity?: string
1076
1134
  }) => {
1077
1135
  try {
1078
1136
  // Set data directory early, before any database access
@@ -1081,6 +1139,15 @@ cli
1081
1139
  cliLogger.log(`Using data directory: ${getDataDir()}`)
1082
1140
  }
1083
1141
 
1142
+ if (options.verbosity) {
1143
+ if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
1144
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`)
1145
+ process.exit(EXIT_NO_RESTART)
1146
+ }
1147
+ setDefaultVerbosity(options.verbosity)
1148
+ cliLogger.log(`Default verbosity: ${options.verbosity}`)
1149
+ }
1150
+
1084
1151
  if (options.installUrl) {
1085
1152
  const db = getDatabase()
1086
1153
  const existingBot = db
@@ -1240,8 +1307,11 @@ cli
1240
1307
  .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1241
1308
  .get() as { app_id: string } | undefined
1242
1309
  appId = botRow?.app_id
1243
- } catch {
1244
- // Database might not exist in CI, that's ok
1310
+ } catch (error) {
1311
+ cliLogger.debug(
1312
+ 'Database lookup failed while resolving app ID:',
1313
+ error instanceof Error ? error.message : String(error),
1314
+ )
1245
1315
  }
1246
1316
  }
1247
1317
  } else {
@@ -1356,8 +1426,11 @@ cli
1356
1426
  if (ch && 'guild' in ch && ch.guild) {
1357
1427
  return ch.guild
1358
1428
  }
1359
- } catch {
1360
- // Channel might be deleted, continue
1429
+ } catch (error) {
1430
+ cliLogger.debug(
1431
+ 'Failed to fetch existing channel while selecting guild:',
1432
+ error instanceof Error ? error.message : String(error),
1433
+ )
1361
1434
  }
1362
1435
  }
1363
1436
  // Fall back to first guild the bot is in
@@ -1597,8 +1670,11 @@ cli
1597
1670
  .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1598
1671
  .get() as { app_id: string } | undefined
1599
1672
  appId = botRow?.app_id
1600
- } catch {
1601
- // Database might not exist in CI
1673
+ } catch (error) {
1674
+ cliLogger.debug(
1675
+ 'Database lookup failed while resolving app ID:',
1676
+ error instanceof Error ? error.message : String(error),
1677
+ )
1602
1678
  }
1603
1679
  }
1604
1680
  } else {
@@ -1677,8 +1753,11 @@ cli
1677
1753
  } else {
1678
1754
  throw new Error('Channel has no guild')
1679
1755
  }
1680
- } catch {
1681
- // Channel might be deleted, fall back to first guild
1756
+ } catch (error) {
1757
+ cliLogger.debug(
1758
+ 'Failed to fetch existing channel while selecting guild:',
1759
+ error instanceof Error ? error.message : String(error),
1760
+ )
1682
1761
  const firstGuild = client.guilds.cache.first()
1683
1762
  if (!firstGuild) {
1684
1763
  s.stop('No guild found')
@@ -1722,12 +1801,18 @@ cli
1722
1801
  client.destroy()
1723
1802
  process.exit(0)
1724
1803
  }
1725
- } catch {
1726
- // Channel might be deleted, continue checking
1804
+ } catch (error) {
1805
+ cliLogger.debug(
1806
+ `Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`,
1807
+ error instanceof Error ? error.message : String(error),
1808
+ )
1727
1809
  }
1728
1810
  }
1729
- } catch {
1730
- // Database might not exist, continue to create
1811
+ } catch (error) {
1812
+ cliLogger.debug(
1813
+ 'Database lookup failed while checking existing channels:',
1814
+ error instanceof Error ? error.message : String(error),
1815
+ )
1731
1816
  }
1732
1817
 
1733
1818
  s.message(`Creating channels in ${guild.name}...`)
@@ -67,6 +67,7 @@ export async function handleAbortCommand({ command }: CommandContext): Promise<v
67
67
 
68
68
  const existingController = abortControllers.get(sessionId)
69
69
  if (existingController) {
70
+ logger.log(`[ABORT] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - user ran /abort command`)
70
71
  existingController.abort(new Error('User requested abort'))
71
72
  abortControllers.delete(sessionId)
72
73
  }
@@ -82,6 +83,7 @@ export async function handleAbortCommand({ command }: CommandContext): Promise<v
82
83
  }
83
84
 
84
85
  try {
86
+ logger.log(`[ABORT-API] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - sending API abort from /abort command`)
85
87
  await getClient().session.abort({
86
88
  path: { id: sessionId },
87
89
  })
@@ -1,8 +1,10 @@
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
 
3
- import { ChannelType, type TextChannel } from 'discord.js'
4
+ import { ChannelType, type Guild, type TextChannel } from 'discord.js'
4
5
  import fs from 'node:fs'
5
6
  import path from 'node:path'
7
+ import { execSync } from 'node:child_process'
6
8
  import type { CommandContext } from './types.js'
7
9
  import { getProjectsDir } from '../config.js'
8
10
  import { createProjectChannels } from '../channel-management.js'
@@ -12,6 +14,67 @@ import { createLogger, LogPrefix } from '../logger.js'
12
14
 
13
15
  const logger = createLogger(LogPrefix.CREATE_PROJECT)
14
16
 
17
+ /**
18
+ * Core project creation logic: creates directory, inits git, creates Discord channels.
19
+ * Reused by the slash command handler and by onboarding (welcome channel).
20
+ * Returns null if the project directory already exists.
21
+ */
22
+ export async function createNewProject({
23
+ guild,
24
+ projectName,
25
+ appId,
26
+ botName,
27
+ }: {
28
+ guild: Guild
29
+ projectName: string
30
+ appId: string
31
+ botName?: string
32
+ }): Promise<{
33
+ textChannelId: string
34
+ voiceChannelId: string
35
+ channelName: string
36
+ projectDirectory: string
37
+ sanitizedName: string
38
+ } | null> {
39
+ const sanitizedName = projectName
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9-]/g, '-')
42
+ .replace(/-+/g, '-')
43
+ .replace(/^-|-$/g, '')
44
+ .slice(0, 100)
45
+
46
+ if (!sanitizedName) {
47
+ return null
48
+ }
49
+
50
+ const projectsDir = getProjectsDir()
51
+ const projectDirectory = path.join(projectsDir, sanitizedName)
52
+
53
+ if (!fs.existsSync(projectsDir)) {
54
+ fs.mkdirSync(projectsDir, { recursive: true })
55
+ logger.log(`Created projects directory: ${projectsDir}`)
56
+ }
57
+
58
+ if (fs.existsSync(projectDirectory)) {
59
+ return null
60
+ }
61
+
62
+ fs.mkdirSync(projectDirectory, { recursive: true })
63
+ logger.log(`Created project directory: ${projectDirectory}`)
64
+
65
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
66
+ logger.log(`Initialized git in: ${projectDirectory}`)
67
+
68
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
69
+ guild,
70
+ projectDirectory,
71
+ appId,
72
+ botName,
73
+ })
74
+
75
+ return { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName }
76
+ }
77
+
15
78
  export async function handleCreateNewProjectCommand({
16
79
  command,
17
80
  appId,
@@ -32,45 +95,33 @@ export async function handleCreateNewProjectCommand({
32
95
  return
33
96
  }
34
97
 
35
- const sanitizedName = projectName
36
- .toLowerCase()
37
- .replace(/[^a-z0-9-]/g, '-')
38
- .replace(/-+/g, '-')
39
- .replace(/^-|-$/g, '')
40
- .slice(0, 100)
41
-
42
- if (!sanitizedName) {
43
- await command.editReply('Invalid project name')
44
- return
45
- }
98
+ try {
99
+ const result = await createNewProject({
100
+ guild,
101
+ projectName,
102
+ appId,
103
+ botName: command.client.user?.username,
104
+ })
46
105
 
47
- const projectsDir = getProjectsDir()
48
- const projectDirectory = path.join(projectsDir, sanitizedName)
106
+ if (!result) {
107
+ const sanitizedName = projectName
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9-]/g, '-')
110
+ .replace(/-+/g, '-')
111
+ .replace(/^-|-$/g, '')
112
+ .slice(0, 100)
49
113
 
50
- try {
51
- if (!fs.existsSync(projectsDir)) {
52
- fs.mkdirSync(projectsDir, { recursive: true })
53
- logger.log(`Created projects directory: ${projectsDir}`)
54
- }
114
+ if (!sanitizedName) {
115
+ await command.editReply('Invalid project name')
116
+ return
117
+ }
55
118
 
56
- if (fs.existsSync(projectDirectory)) {
119
+ const projectDirectory = path.join(getProjectsDir(), sanitizedName)
57
120
  await command.editReply(`Project directory already exists: ${projectDirectory}`)
58
121
  return
59
122
  }
60
123
 
61
- fs.mkdirSync(projectDirectory, { recursive: true })
62
- logger.log(`Created project directory: ${projectDirectory}`)
63
-
64
- const { execSync } = await import('node:child_process')
65
- execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
66
- logger.log(`Initialized git in: ${projectDirectory}`)
67
-
68
- const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
69
- guild,
70
- projectDirectory,
71
- appId,
72
- })
73
-
124
+ const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result
74
125
  const textChannel = (await guild.channels.fetch(textChannelId)) as TextChannel
75
126
 
76
127
  await command.editReply(
@@ -47,7 +47,11 @@ async function isDetachedHead(worktreeDir: string): Promise<boolean> {
47
47
  try {
48
48
  await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`)
49
49
  return false
50
- } catch {
50
+ } catch (error) {
51
+ logger.debug(
52
+ `Failed to resolve HEAD for ${worktreeDir}:`,
53
+ error instanceof Error ? error.message : String(error),
54
+ )
51
55
  return true
52
56
  }
53
57
  }
@@ -59,7 +63,11 @@ async function getCurrentBranch(worktreeDir: string): Promise<string | null> {
59
63
  try {
60
64
  const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`)
61
65
  return stdout.trim() || null
62
- } catch {
66
+ } catch (error) {
67
+ logger.debug(
68
+ `Failed to get current branch for ${worktreeDir}:`,
69
+ error instanceof Error ? error.message : String(error),
70
+ )
63
71
  return null
64
72
  }
65
73
  }
@@ -113,7 +121,11 @@ export async function handleMergeWorktreeCommand({ command, appId }: CommandCont
113
121
  `git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`,
114
122
  )
115
123
  defaultBranch = stdout.trim() || 'main'
116
- } catch {
124
+ } catch (error) {
125
+ logger.warn(
126
+ `Failed to detect default branch for ${mainRepoDir}, falling back to main:`,
127
+ error instanceof Error ? error.message : String(error),
128
+ )
117
129
  defaultBranch = 'main'
118
130
  }
119
131
 
@@ -141,11 +153,26 @@ export async function handleMergeWorktreeCommand({ command, appId }: CommandCont
141
153
  await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`)
142
154
  } catch (e) {
143
155
  // If merge fails (conflicts), abort and report
144
- await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => {})
156
+ await execAsync(`git -C "${worktreeDir}" merge --abort`).catch((error) => {
157
+ logger.warn(
158
+ `Failed to abort merge in ${worktreeDir}:`,
159
+ error instanceof Error ? error.message : String(error),
160
+ )
161
+ })
145
162
  // Clean up temp branch if we created one
146
163
  if (tempBranch) {
147
- await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => {})
148
- await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => {})
164
+ await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch((error) => {
165
+ logger.warn(
166
+ `Failed to detach HEAD after merge conflict in ${worktreeDir}:`,
167
+ error instanceof Error ? error.message : String(error),
168
+ )
169
+ })
170
+ await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch((error) => {
171
+ logger.warn(
172
+ `Failed to delete temp branch ${tempBranch} in ${worktreeDir}:`,
173
+ error instanceof Error ? error.message : String(error),
174
+ )
175
+ })
149
176
  }
150
177
  throw new Error(`Merge conflict - resolve manually in worktree then retry`)
151
178
  }
@@ -162,11 +189,21 @@ export async function handleMergeWorktreeCommand({ command, appId }: CommandCont
162
189
 
163
190
  // 7. Delete the merged branch (temp or original)
164
191
  logger.log(`Deleting merged branch ${branchToMerge}`)
165
- await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => {})
192
+ await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch((error) => {
193
+ logger.warn(
194
+ `Failed to delete merged branch ${branchToMerge} in ${worktreeDir}:`,
195
+ error instanceof Error ? error.message : String(error),
196
+ )
197
+ })
166
198
 
167
199
  // Also delete the original worktree branch if different from what we merged
168
200
  if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
169
- await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => {})
201
+ await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch((error) => {
202
+ logger.warn(
203
+ `Failed to delete worktree branch ${worktreeInfo.worktree_name} in ${worktreeDir}:`,
204
+ error instanceof Error ? error.message : String(error),
205
+ )
206
+ })
170
207
  }
171
208
 
172
209
  // 8. Remove worktree prefix from thread title (fire and forget with timeout)
@@ -35,10 +35,12 @@ export async function showPermissionDropdown({
35
35
  thread,
36
36
  permission,
37
37
  directory,
38
+ subtaskLabel,
38
39
  }: {
39
40
  thread: ThreadChannel
40
41
  permission: PermissionRequest
41
42
  directory: string
43
+ subtaskLabel?: string
42
44
  }): Promise<{ messageId: string; contextHash: string }> {
43
45
  const contextHash = crypto.randomBytes(8).toString('hex')
44
46
 
@@ -80,9 +82,11 @@ export async function showPermissionDropdown({
80
82
 
81
83
  const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
82
84
 
85
+ const subtaskLine = subtaskLabel ? `**From:** \`${subtaskLabel}\`\n` : ''
83
86
  const permissionMessage = await thread.send({
84
87
  content:
85
88
  `⚠️ **Permission Required**\n\n` +
89
+ subtaskLine +
86
90
  `**Type:** \`${permission.permission}\`\n` +
87
91
  (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
88
92
  components: [actionRow],
@@ -133,7 +133,10 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
133
133
  }
134
134
 
135
135
  const agents = agentsResponse.data
136
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
136
+ .filter((a) => {
137
+ const hidden = (a as { hidden?: boolean }).hidden
138
+ return (a.mode === 'primary' || a.mode === 'all') && !hidden
139
+ })
137
140
  .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
138
141
  .slice(0, 25)
139
142
 
@@ -11,7 +11,7 @@ const verbosityLogger = createLogger(LogPrefix.VERBOSITY)
11
11
 
12
12
  /**
13
13
  * Handle the /verbosity slash command.
14
- * Sets output verbosity for the channel (applies to new sessions).
14
+ * Sets output verbosity for the channel (applies immediately, even mid-session).
15
15
  */
16
16
  export async function handleVerbosityCommand({
17
17
  command,
@@ -51,7 +51,7 @@ export async function handleVerbosityCommand({
51
51
 
52
52
  if (currentLevel === level) {
53
53
  await command.reply({
54
- content: `Verbosity is already set to **${level}**.`,
54
+ content: `Verbosity is already set to **${level}** for this channel.`,
55
55
  ephemeral: true,
56
56
  })
57
57
  return
@@ -65,7 +65,7 @@ export async function handleVerbosityCommand({
65
65
  : 'All output will be shown, including tool executions and status messages.'
66
66
 
67
67
  await command.reply({
68
- content: `Verbosity set to **${level}**.\n${description}\nThis applies to all new sessions in this channel.`,
68
+ content: `Verbosity set to **${level}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
69
69
  ephemeral: true,
70
70
  })
71
71
  }
package/src/config.ts CHANGED
@@ -44,6 +44,20 @@ export function getProjectsDir(): string {
44
44
  return path.join(getDataDir(), 'projects')
45
45
  }
46
46
 
47
+ // Default verbosity for channels that haven't set a per-channel override.
48
+ // Set via --verbosity CLI flag at startup.
49
+ import type { VerbosityLevel } from './database.js'
50
+
51
+ let defaultVerbosity: VerbosityLevel = 'tools-and-text'
52
+
53
+ export function getDefaultVerbosity(): VerbosityLevel {
54
+ return defaultVerbosity
55
+ }
56
+
57
+ export function setDefaultVerbosity(level: VerbosityLevel): void {
58
+ defaultVerbosity = level
59
+ }
60
+
47
61
  const DEFAULT_LOCK_PORT = 29988
48
62
 
49
63
  /**
package/src/database.ts CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import * as errore from 'errore'
9
9
  import { createLogger, LogPrefix } from './logger.js'
10
- import { getDataDir } from './config.js'
10
+ import { getDataDir, getDefaultVerbosity } from './config.js'
11
11
 
12
12
  const dbLogger = createLogger(LogPrefix.DB)
13
13
 
@@ -69,8 +69,11 @@ export function getDatabase(): Database.Database {
69
69
  // Migration: add app_id column to channel_directories for multi-bot support
70
70
  try {
71
71
  db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
72
- } catch {
73
- // Column already exists, ignore
72
+ } catch (error) {
73
+ dbLogger.debug(
74
+ 'Failed to add app_id column to channel_directories (likely exists):',
75
+ error instanceof Error ? error.message : String(error),
76
+ )
74
77
  }
75
78
 
76
79
  // Table for threads that should auto-start a session (created by CLI without --notify-only)
@@ -378,14 +381,17 @@ export function runVerbosityMigrations(database?: Database.Database): void {
378
381
 
379
382
  /**
380
383
  * Get the verbosity setting for a channel.
381
- * @returns 'tools-and-text' (default) or 'text-only'
384
+ * Falls back to the global default set via --verbosity CLI flag if no per-channel override exists.
382
385
  */
383
386
  export function getChannelVerbosity(channelId: string): VerbosityLevel {
384
387
  const db = getDatabase()
385
388
  const row = db
386
389
  .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
387
390
  .get(channelId) as { verbosity: string } | undefined
388
- return (row?.verbosity as VerbosityLevel) || 'tools-and-text'
391
+ if (row?.verbosity) {
392
+ return row.verbosity as VerbosityLevel
393
+ }
394
+ return getDefaultVerbosity()
389
395
  }
390
396
 
391
397
  /**