kimaki 0.4.78 → 0.4.80

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.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
package/src/database.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { getPrisma, closePrisma } from './db.js'
6
6
  import type { Prisma, session_events, BotMode, VerbosityLevel, WorktreeStatus, ChannelType as PrismaChannelType } from './generated/client.js'
7
+ import crypto from 'node:crypto'
7
8
 
8
9
  import { store } from './store.js'
9
10
  import { createLogger, LogPrefix } from './logger.js'
@@ -209,6 +210,65 @@ export async function listScheduledTasks({
209
210
  return rows.map((row) => toScheduledTask(row))
210
211
  }
211
212
 
213
+ export async function getScheduledTask(
214
+ taskId: number,
215
+ ): Promise<ScheduledTask | null> {
216
+ const prisma = await getPrisma()
217
+ const row = await prisma.scheduled_tasks.findUnique({
218
+ where: { id: taskId },
219
+ })
220
+ return row ? toScheduledTask(row) : null
221
+ }
222
+
223
+ export async function updateScheduledTask({
224
+ taskId,
225
+ payloadJson,
226
+ promptPreview,
227
+ scheduleKind,
228
+ runAt,
229
+ cronExpr,
230
+ timezone,
231
+ nextRunAt,
232
+ }: {
233
+ taskId: number
234
+ payloadJson: string
235
+ promptPreview: string
236
+ scheduleKind?: ScheduledTaskScheduleKind
237
+ runAt?: Date | null
238
+ cronExpr?: string | null
239
+ timezone?: string | null
240
+ nextRunAt?: Date
241
+ }): Promise<boolean> {
242
+ const prisma = await getPrisma()
243
+ const data: Record<string, unknown> = {
244
+ payload_json: payloadJson,
245
+ prompt_preview: promptPreview,
246
+ }
247
+ if (scheduleKind !== undefined) {
248
+ data.schedule_kind = scheduleKind
249
+ }
250
+ if (runAt !== undefined) {
251
+ data.run_at = runAt
252
+ }
253
+ if (cronExpr !== undefined) {
254
+ data.cron_expr = cronExpr
255
+ }
256
+ if (timezone !== undefined) {
257
+ data.timezone = timezone
258
+ }
259
+ if (nextRunAt !== undefined) {
260
+ data.next_run_at = nextRunAt
261
+ }
262
+ const result = await prisma.scheduled_tasks.updateMany({
263
+ where: {
264
+ id: taskId,
265
+ status: 'planned',
266
+ },
267
+ data,
268
+ })
269
+ return result.count > 0
270
+ }
271
+
212
272
  export async function cancelScheduledTask(taskId: number): Promise<boolean> {
213
273
  const prisma = await getPrisma()
214
274
  const result = await prisma.scheduled_tasks.updateMany({
@@ -1173,6 +1233,7 @@ export async function getBotTokenWithMode(): Promise<
1173
1233
  | {
1174
1234
  appId: string
1175
1235
  token: string
1236
+ gatewayToken: string
1176
1237
  mode: BotMode
1177
1238
  clientId: string | null
1178
1239
  clientSecret: string | null
@@ -1191,9 +1252,11 @@ export async function getBotTokenWithMode(): Promise<
1191
1252
  if (!row) {
1192
1253
  return undefined
1193
1254
  }
1255
+ const gatewayToken = await ensureServiceAuthToken({ appId: row.app_id })
1256
+ const serviceParts = splitServiceAuthToken({ token: gatewayToken })
1194
1257
  const mode: BotMode = row.bot_mode === 'gateway' ? 'gateway' : 'self_hosted'
1195
- const token = (mode === 'gateway' && row.client_id && row.client_secret)
1196
- ? `${row.client_id}:${row.client_secret}`
1258
+ const token = (mode === 'gateway' && serviceParts)
1259
+ ? gatewayToken
1197
1260
  : row.token
1198
1261
  // Always reset discordBaseUrl on every read so a mode switch within
1199
1262
  // the same process (e.g. DB has gateway row but user proceeds self-hosted)
@@ -1201,27 +1264,90 @@ export async function getBotTokenWithMode(): Promise<
1201
1264
  const discordBaseUrl = (mode === 'gateway' && row.proxy_url)
1202
1265
  ? row.proxy_url
1203
1266
  : 'https://discord.com'
1204
- store.setState({ discordBaseUrl })
1267
+ store.setState({ discordBaseUrl, gatewayToken })
1205
1268
  return {
1206
1269
  appId: row.app_id,
1207
1270
  token,
1271
+ gatewayToken,
1208
1272
  mode,
1209
- clientId: row.client_id,
1210
- clientSecret: row.client_secret,
1273
+ clientId: serviceParts?.clientId || row.client_id,
1274
+ clientSecret: serviceParts?.clientSecret || row.client_secret,
1211
1275
  proxyUrl: row.proxy_url,
1212
1276
  }
1213
1277
  }
1214
1278
 
1279
+ function splitServiceAuthToken({ token }: { token: string }): { clientId: string; clientSecret: string } | null {
1280
+ const separatorIndex = token.indexOf(':')
1281
+ if (separatorIndex <= 0 || separatorIndex >= token.length - 1) {
1282
+ return null
1283
+ }
1284
+ return {
1285
+ clientId: token.slice(0, separatorIndex),
1286
+ clientSecret: token.slice(separatorIndex + 1),
1287
+ }
1288
+ }
1289
+
1290
+ function createServiceCredentials(): { clientId: string; clientSecret: string } {
1291
+ return {
1292
+ clientId: crypto.randomUUID(),
1293
+ clientSecret: crypto.randomBytes(32).toString('hex'),
1294
+ }
1295
+ }
1296
+
1297
+ export async function ensureServiceAuthToken({
1298
+ appId,
1299
+ preferredGatewayToken,
1300
+ }: {
1301
+ appId: string
1302
+ preferredGatewayToken?: string
1303
+ }): Promise<string> {
1304
+ const prisma = await getPrisma()
1305
+ const row = await prisma.bot_tokens.findUnique({
1306
+ where: { app_id: appId },
1307
+ })
1308
+ if (!row) {
1309
+ throw new Error(`Bot token row not found for app_id ${appId}`)
1310
+ }
1311
+
1312
+ const preferred = preferredGatewayToken
1313
+ ? splitServiceAuthToken({ token: preferredGatewayToken })
1314
+ : null
1315
+ const existing = (row.client_id && row.client_secret)
1316
+ ? { clientId: row.client_id, clientSecret: row.client_secret }
1317
+ : null
1318
+ const fromStoredToken = splitServiceAuthToken({ token: row.token })
1319
+ const resolved = preferred || existing || fromStoredToken || createServiceCredentials()
1320
+
1321
+ if (row.client_id !== resolved.clientId || row.client_secret !== resolved.clientSecret) {
1322
+ await prisma.bot_tokens.update({
1323
+ where: { app_id: appId },
1324
+ data: {
1325
+ client_id: resolved.clientId,
1326
+ client_secret: resolved.clientSecret,
1327
+ },
1328
+ })
1329
+ }
1330
+
1331
+ return `${resolved.clientId}:${resolved.clientSecret}`
1332
+ }
1333
+
1215
1334
  /**
1216
1335
  * Store a bot token.
1217
1336
  */
1218
1337
  export async function setBotToken(appId: string, token: string): Promise<void> {
1219
1338
  const prisma = await getPrisma()
1339
+ const generated = createServiceCredentials()
1220
1340
  await prisma.bot_tokens.upsert({
1221
1341
  where: { app_id: appId },
1222
- create: { app_id: appId, token },
1342
+ create: {
1343
+ app_id: appId,
1344
+ token,
1345
+ client_id: generated.clientId,
1346
+ client_secret: generated.clientSecret,
1347
+ },
1223
1348
  update: { token },
1224
1349
  })
1350
+ await ensureServiceAuthToken({ appId })
1225
1351
  }
1226
1352
 
1227
1353
  export type { BotMode }
@@ -1250,11 +1376,16 @@ export async function setBotMode({
1250
1376
  client_secret: clientSecret ?? null,
1251
1377
  proxy_url: proxyUrl ?? null,
1252
1378
  }
1379
+ const createToken = (clientId && clientSecret) ? `${clientId}:${clientSecret}` : ''
1253
1380
  await prisma.bot_tokens.upsert({
1254
1381
  where: { app_id: appId },
1255
- create: { app_id: appId, token: `${clientId}:${clientSecret}`, ...data },
1382
+ create: { app_id: appId, token: createToken, ...data },
1256
1383
  update: data,
1257
1384
  })
1385
+ await ensureServiceAuthToken({
1386
+ appId,
1387
+ preferredGatewayToken: (clientId && clientSecret) ? `${clientId}:${clientSecret}` : undefined,
1388
+ })
1258
1389
  }
1259
1390
 
1260
1391
 
package/src/db.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
+ import crypto from 'node:crypto'
7
8
  import { PrismaLibSql } from '@prisma/adapter-libsql'
8
9
  import { PrismaClient, Prisma } from './generated/client.js'
9
10
  import { getDataDir } from './config.js'
@@ -60,6 +61,14 @@ function getDbUrl(): string {
60
61
  return `file:${dbPath}`
61
62
  }
62
63
 
64
+ function getDbAuthToken(): string | undefined {
65
+ const token = process.env.KIMAKI_DB_AUTH_TOKEN
66
+ if (!token) {
67
+ return undefined
68
+ }
69
+ return token
70
+ }
71
+
63
72
  async function initializePrisma(): Promise<PrismaClient> {
64
73
  const dbUrl = getDbUrl()
65
74
  const isFileMode = dbUrl.startsWith('file:')
@@ -78,7 +87,11 @@ async function initializePrisma(): Promise<PrismaClient> {
78
87
 
79
88
  dbLogger.log(`Opening database via: ${dbUrl}`)
80
89
 
81
- const adapter = new PrismaLibSql({ url: dbUrl })
90
+ const dbAuthToken = getDbAuthToken()
91
+ const adapter = new PrismaLibSql({
92
+ url: dbUrl,
93
+ ...(dbAuthToken && { authToken: dbAuthToken }),
94
+ })
82
95
  const prisma = new PrismaClient({ adapter })
83
96
 
84
97
  try {
@@ -224,6 +237,32 @@ async function migrateSchema(prisma: PrismaClient): Promise<void> {
224
237
  }
225
238
  }
226
239
 
240
+ // Migration: ensure every bot row has service auth credentials.
241
+ // These credentials are used for local/internet control-plane auth.
242
+ try {
243
+ const botRows = await prisma.bot_tokens.findMany({
244
+ select: {
245
+ app_id: true,
246
+ client_id: true,
247
+ client_secret: true,
248
+ },
249
+ })
250
+ for (const botRow of botRows) {
251
+ if (botRow.client_id && botRow.client_secret) {
252
+ continue
253
+ }
254
+ await prisma.bot_tokens.update({
255
+ where: { app_id: botRow.app_id },
256
+ data: {
257
+ client_id: crypto.randomUUID(),
258
+ client_secret: crypto.randomBytes(32).toString('hex'),
259
+ },
260
+ })
261
+ }
262
+ } catch {
263
+ // Defensive migration only; ignore if table shape is not ready yet.
264
+ }
265
+
227
266
  }
228
267
 
229
268
  /**
@@ -6,6 +6,7 @@ import {
6
6
  initDatabase,
7
7
  closeDatabase,
8
8
  getThreadWorktree,
9
+ getThreadSession,
9
10
  createPendingWorktree,
10
11
  setWorktreeReady,
11
12
  setWorktreeError,
@@ -26,6 +27,7 @@ import {
26
27
  splitMarkdownForDiscord,
27
28
  sendThreadMessage,
28
29
  SILENT_MESSAGE_FLAGS,
30
+ NOTIFY_MESSAGE_FLAGS,
29
31
  reactToThread,
30
32
  stripMentions,
31
33
  hasKimakiBotPermission,
@@ -40,6 +42,7 @@ import {
40
42
  getTextAttachments,
41
43
  resolveMentions,
42
44
  } from './message-formatting.js'
45
+ import { isVoiceAttachment } from './voice-attachment.js'
43
46
  import {
44
47
  preprocessExistingThreadMessage,
45
48
  preprocessNewThreadMessage,
@@ -71,7 +74,7 @@ import {
71
74
  import { runShellCommand } from './commands/run-command.js'
72
75
  import { registerInteractionHandler } from './interaction-handler.js'
73
76
  import { getDiscordRestApiUrl } from './discord-urls.js'
74
- import { stopHranaServer } from './hrana-server.js'
77
+ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js'
75
78
  import { notifyError } from './sentry.js'
76
79
  import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js'
77
80
  import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js'
@@ -276,6 +279,7 @@ export async function startDiscordBot({
276
279
  }
277
280
 
278
281
  voiceLogger.log('[READY] Bot is ready')
282
+ markDiscordGatewayReady()
279
283
 
280
284
  registerInteractionHandler({ discordClient: c, appId: currentAppId })
281
285
  registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
@@ -299,7 +303,7 @@ export async function startDiscordBot({
299
303
  }
300
304
  })().catch((error) => {
301
305
  discordLogger.warn(
302
- `Background guild channel scan failed: ${error instanceof Error ? error.message : String(error)}`,
306
+ `Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`,
303
307
  )
304
308
  })
305
309
  }
@@ -409,6 +413,9 @@ export async function startDiscordBot({
409
413
  const cliInjectedModel = isCliInjectedPrompt
410
414
  ? promptMarker?.model
411
415
  : undefined
416
+ const cliInjectedPermissions = isCliInjectedPrompt
417
+ ? promptMarker?.permissions
418
+ : undefined
412
419
 
413
420
  // Always ignore our own messages (unless CLI-injected prompt above).
414
421
  // Without this, assigning the Kimaki role to the bot itself would loop.
@@ -496,6 +503,19 @@ export async function startDiscordBot({
496
503
  const thread = channel as ThreadChannel
497
504
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
498
505
 
506
+ // Only respond in threads kimaki knows about (has a session row in DB)
507
+ // or where the bot is explicitly @mentioned. This prevents the bot from
508
+ // hijacking user-created threads in project channels. (GitHub #84)
509
+ const hasExistingSession = await getThreadSession(thread.id)
510
+ const botMentioned =
511
+ discordClient.user && message.mentions.has(discordClient.user.id)
512
+ if (!hasExistingSession && !botMentioned && !isCliInjectedPrompt) {
513
+ discordLogger.log(
514
+ `Ignoring thread ${thread.id}: no existing session and bot not mentioned`,
515
+ )
516
+ return
517
+ }
518
+
499
519
  const parent = thread.parent as TextChannel | null
500
520
  let projectDirectory: string | undefined
501
521
  if (parent) {
@@ -518,7 +538,7 @@ export async function startDiscordBot({
518
538
  if (worktreeInfo.status === 'error') {
519
539
  await message.reply({
520
540
  content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
521
- flags: SILENT_MESSAGE_FLAGS,
541
+ flags: NOTIFY_MESSAGE_FLAGS,
522
542
  })
523
543
  return
524
544
  }
@@ -536,7 +556,7 @@ export async function startDiscordBot({
536
556
  discordLogger.error(`Directory does not exist: ${projectDirectory}`)
537
557
  await message.reply({
538
558
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
539
- flags: SILENT_MESSAGE_FLAGS,
559
+ flags: NOTIFY_MESSAGE_FLAGS,
540
560
  })
541
561
  return
542
562
  }
@@ -563,8 +583,8 @@ export async function startDiscordBot({
563
583
  }
564
584
  }
565
585
 
566
- const hasVoiceAttachment = message.attachments.some((a) => {
567
- return a.contentType?.startsWith('audio/')
586
+ const hasVoiceAttachment = message.attachments.some((attachment) => {
587
+ return isVoiceAttachment(attachment)
568
588
  })
569
589
 
570
590
  if (!projectDirectory) {
@@ -622,6 +642,7 @@ export async function startDiscordBot({
622
642
  appId: currentAppId,
623
643
  agent: cliInjectedAgent,
624
644
  model: cliInjectedModel,
645
+ permissions: cliInjectedPermissions,
625
646
  sessionStartSource: sessionStartSource
626
647
  ? {
627
648
  scheduleKind: sessionStartSource.scheduleKind,
@@ -687,7 +708,7 @@ export async function startDiscordBot({
687
708
  discordLogger.error(`Directory does not exist: ${projectDirectory}`)
688
709
  await message.reply({
689
710
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
690
- flags: SILENT_MESSAGE_FLAGS,
711
+ flags: NOTIFY_MESSAGE_FLAGS,
691
712
  })
692
713
  return
693
714
  }
@@ -708,9 +729,9 @@ export async function startDiscordBot({
708
729
  }
709
730
  }
710
731
 
711
- const hasVoice = message.attachments.some((a) =>
712
- a.contentType?.startsWith('audio/'),
713
- )
732
+ const hasVoice = message.attachments.some((attachment) => {
733
+ return isVoiceAttachment(attachment)
734
+ })
714
735
 
715
736
  const baseThreadName = hasVoice
716
737
  ? 'Voice Message'
@@ -767,7 +788,7 @@ export async function startDiscordBot({
767
788
  })
768
789
  await thread.send({
769
790
  content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
770
- flags: SILENT_MESSAGE_FLAGS,
791
+ flags: NOTIFY_MESSAGE_FLAGS,
771
792
  })
772
793
  } else {
773
794
  await setWorktreeReady({
@@ -824,7 +845,7 @@ export async function startDiscordBot({
824
845
  ).slice(0, 1900)
825
846
  await message.reply({
826
847
  content: `Error: ${errMsg}`,
827
- flags: SILENT_MESSAGE_FLAGS,
848
+ flags: NOTIFY_MESSAGE_FLAGS,
828
849
  })
829
850
  } catch (sendError) {
830
851
  voiceLogger.error(
@@ -855,7 +876,7 @@ export async function startDiscordBot({
855
876
  .catch((error) => {
856
877
  discordLogger.warn(
857
878
  `[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
858
- error instanceof Error ? error.message : String(error),
879
+ error instanceof Error ? error.stack : String(error),
859
880
  )
860
881
  return null
861
882
  })
@@ -915,7 +936,7 @@ export async function startDiscordBot({
915
936
  )
916
937
  await thread.send({
917
938
  content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
918
- flags: SILENT_MESSAGE_FLAGS,
939
+ flags: NOTIFY_MESSAGE_FLAGS,
919
940
  })
920
941
  return
921
942
  }
@@ -958,11 +979,11 @@ export async function startDiscordBot({
958
979
  })
959
980
  await (worktreeStatusMessage?.edit({
960
981
  content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
961
- flags: SILENT_MESSAGE_FLAGS,
982
+ flags: NOTIFY_MESSAGE_FLAGS,
962
983
  }) ||
963
984
  thread.send({
964
985
  content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
965
- flags: SILENT_MESSAGE_FLAGS,
986
+ flags: NOTIFY_MESSAGE_FLAGS,
966
987
  }))
967
988
  return projectDirectory
968
989
  }
@@ -1013,6 +1034,7 @@ export async function startDiscordBot({
1013
1034
  appId: currentAppId,
1014
1035
  agent: marker.agent,
1015
1036
  model: marker.model,
1037
+ permissions: marker.permissions,
1016
1038
  mode: 'opencode',
1017
1039
  sessionStartSource: botThreadStartSource
1018
1040
  ? {
@@ -1033,7 +1055,7 @@ export async function startDiscordBot({
1033
1055
  ).slice(0, 1900)
1034
1056
  await thread.send({
1035
1057
  content: `Error: ${errMsg}`,
1036
- flags: SILENT_MESSAGE_FLAGS,
1058
+ flags: NOTIFY_MESSAGE_FLAGS,
1037
1059
  })
1038
1060
  } catch (sendError) {
1039
1061
  voiceLogger.error(
@@ -1050,7 +1072,12 @@ export async function startDiscordBot({
1050
1072
  disposeRuntime(thread.id)
1051
1073
  })
1052
1074
 
1053
- await discordClient.login(token)
1075
+ // Skip login if the caller already connected the client (e.g. cli.ts logs in
1076
+ // before calling startDiscordBot). Calling login() again destroys the existing
1077
+ // WebSocket (close code 1000) and triggers a spurious ShardReconnecting event.
1078
+ if (!discordClient.isReady()) {
1079
+ await discordClient.login(token)
1080
+ }
1054
1081
 
1055
1082
  startHeapMonitor()
1056
1083
  const stopTaskRunner = startTaskRunner({ token })
@@ -1072,7 +1099,7 @@ export async function startDiscordBot({
1072
1099
  await flushDebouncedProcessCallbacks().catch((error) => {
1073
1100
  discordLogger.warn(
1074
1101
  'Failed to flush debounced process callbacks:',
1075
- error instanceof Error ? error.message : String(error),
1102
+ error instanceof Error ? error.stack : String(error),
1076
1103
  )
1077
1104
  })
1078
1105
 
@@ -56,6 +56,18 @@ export function createDiscordRest(token: string): REST {
56
56
  return new REST({ api: getDiscordRestApiUrl() }).setToken(token)
57
57
  }
58
58
 
59
+ /**
60
+ * Returns the internet-reachable base URL for this kimaki instance.
61
+ * When KIMAKI_INTERNET_REACHABLE_URL is set (e.g. "https://my-kimaki.fly.dev"),
62
+ * kimaki binds the hrana server to 0.0.0.0 and exposes a /kimaki/wake endpoint
63
+ * so the gateway-proxy can wake this instance. Discord traffic still flows
64
+ * through the normal path (gateway-proxy in gateway mode, direct in self-hosted).
65
+ * Returns null when not set (kimaki only reachable on localhost).
66
+ */
67
+ export function getInternetReachableBaseUrl(): string | null {
68
+ return process.env['KIMAKI_INTERNET_REACHABLE_URL'] || null
69
+ }
70
+
59
71
  /**
60
72
  * Derive an HTTPS REST base URL from a WebSocket gateway URL.
61
73
  * Swaps wss→https and ws→http. Used for gateway mode where the
package/src/errors.ts CHANGED
@@ -160,7 +160,7 @@ export class NotFastForwardError extends createTaggedError({
160
160
  export class ConflictingFilesError extends createTaggedError({
161
161
  name: 'ConflictingFilesError',
162
162
  message:
163
- 'Cannot merge: $target worktree has uncommitted changes in overlapping files',
163
+ 'Cannot merge: $target worktree has uncommitted changes in overlapping files. Commit changes in main worktree first, then run `/merge-worktree` again.',
164
164
  }) {}
165
165
 
166
166
  export class PushError extends createTaggedError({