kimaki 0.4.18 → 0.4.20

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/discordBot.ts CHANGED
@@ -80,6 +80,8 @@ export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
80
80
  return `
81
81
  The user is reading your messages from inside Discord, via kimaki.xyz
82
82
 
83
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
84
+
83
85
  Your current OpenCode session ID is: ${sessionId}
84
86
 
85
87
  ## uploading files to discord
@@ -620,6 +622,7 @@ export function getDatabase(): Database.Database {
620
622
  CREATE TABLE IF NOT EXISTS bot_api_keys (
621
623
  app_id TEXT PRIMARY KEY,
622
624
  gemini_api_key TEXT,
625
+ xai_api_key TEXT,
623
626
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
624
627
  )
625
628
  `)
@@ -877,14 +880,64 @@ async function processVoiceAttachment({
877
880
  return transcription
878
881
  }
879
882
 
880
- function getImageAttachments(message: Message): FilePartInput[] {
881
- const imageAttachments = Array.from(message.attachments.values()).filter(
882
- (attachment) => attachment.contentType?.startsWith('image/'),
883
+ const TEXT_MIME_TYPES = [
884
+ 'text/',
885
+ 'application/json',
886
+ 'application/xml',
887
+ 'application/javascript',
888
+ 'application/typescript',
889
+ 'application/x-yaml',
890
+ 'application/toml',
891
+ ]
892
+
893
+ function isTextMimeType(contentType: string | null): boolean {
894
+ if (!contentType) {
895
+ return false
896
+ }
897
+ return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
898
+ }
899
+
900
+ async function getTextAttachments(message: Message): Promise<string> {
901
+ const textAttachments = Array.from(message.attachments.values()).filter(
902
+ (attachment) => isTextMimeType(attachment.contentType),
903
+ )
904
+
905
+ if (textAttachments.length === 0) {
906
+ return ''
907
+ }
908
+
909
+ const textContents = await Promise.all(
910
+ textAttachments.map(async (attachment) => {
911
+ try {
912
+ const response = await fetch(attachment.url)
913
+ if (!response.ok) {
914
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
915
+ }
916
+ const text = await response.text()
917
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
918
+ } catch (error) {
919
+ const errMsg = error instanceof Error ? error.message : String(error)
920
+ return `<attachment filename="${attachment.name}" error="${errMsg}" />`
921
+ }
922
+ }),
923
+ )
924
+
925
+ return textContents.join('\n\n')
926
+ }
927
+
928
+ function getFileAttachments(message: Message): FilePartInput[] {
929
+ const fileAttachments = Array.from(message.attachments.values()).filter(
930
+ (attachment) => {
931
+ const contentType = attachment.contentType || ''
932
+ return (
933
+ contentType.startsWith('image/') || contentType === 'application/pdf'
934
+ )
935
+ },
883
936
  )
884
937
 
885
- return imageAttachments.map((attachment) => ({
938
+ return fileAttachments.map((attachment) => ({
886
939
  type: 'file' as const,
887
- mime: attachment.contentType || 'image/png',
940
+ mime: attachment.contentType || 'application/octet-stream',
888
941
  filename: attachment.name,
889
942
  url: attachment.url,
890
943
  }))
@@ -1204,13 +1257,6 @@ export async function initializeOpencodeForDirectory(directory: string) {
1204
1257
 
1205
1258
  function getToolSummaryText(part: Part): string {
1206
1259
  if (part.type !== 'tool') return ''
1207
- if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1208
-
1209
- if (part.tool === 'bash') {
1210
- const output = part.state.status === 'completed' ? part.state.output : part.state.error
1211
- const lines = (output || '').split('\n').filter((l: string) => l.trim())
1212
- return `(${lines.length} line${lines.length === 1 ? '' : 's'})`
1213
- }
1214
1260
 
1215
1261
  if (part.tool === 'edit') {
1216
1262
  const newString = (part.state.input?.newString as string) || ''
@@ -1233,6 +1279,7 @@ function getToolSummaryText(part: Part): string {
1233
1279
  }
1234
1280
 
1235
1281
  if (
1282
+ part.tool === 'bash' ||
1236
1283
  part.tool === 'read' ||
1237
1284
  part.tool === 'list' ||
1238
1285
  part.tool === 'glob' ||
@@ -1250,7 +1297,7 @@ function getToolSummaryText(part: Part): string {
1250
1297
  .map(([key, value]) => {
1251
1298
  if (value === null || value === undefined) return null
1252
1299
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1253
- const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue
1300
+ const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
1254
1301
  return `${key}: ${truncatedValue}`
1255
1302
  })
1256
1303
  .filter(Boolean)
@@ -1260,17 +1307,6 @@ function getToolSummaryText(part: Part): string {
1260
1307
  return `(${inputFields.join(', ')})`
1261
1308
  }
1262
1309
 
1263
- function getToolOutputToDisplay(part: Part): string {
1264
- if (part.type !== 'tool') return ''
1265
- if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1266
-
1267
- if (part.state.status === 'error') {
1268
- return part.state.error || 'Unknown error'
1269
- }
1270
-
1271
- return ''
1272
- }
1273
-
1274
1310
  function formatTodoList(part: Part): string {
1275
1311
  if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
1276
1312
  const todos =
@@ -1324,36 +1360,35 @@ function formatPart(part: Part): string {
1324
1360
  return formatTodoList(part)
1325
1361
  }
1326
1362
 
1327
- if (part.state.status !== 'completed' && part.state.status !== 'error') {
1363
+ if (part.state.status === 'pending') {
1328
1364
  return ''
1329
1365
  }
1330
1366
 
1331
1367
  const summaryText = getToolSummaryText(part)
1332
- const outputToDisplay = getToolOutputToDisplay(part)
1368
+ const stateTitle = 'title' in part.state ? part.state.title : undefined
1333
1369
 
1334
1370
  let toolTitle = ''
1335
1371
  if (part.state.status === 'error') {
1336
- toolTitle = 'error'
1372
+ toolTitle = part.state.error || 'error'
1337
1373
  } else if (part.tool === 'bash') {
1338
1374
  const command = (part.state.input?.command as string) || ''
1339
1375
  const isSingleLine = !command.includes('\n')
1340
1376
  const hasBackticks = command.includes('`')
1341
1377
  if (isSingleLine && command.length <= 120 && !hasBackticks) {
1342
- toolTitle = `\`${command}\``
1378
+ toolTitle = `_${command}_`
1343
1379
  } else {
1344
- toolTitle = part.state.title ? `*${part.state.title}*` : ''
1380
+ toolTitle = stateTitle ? `_${stateTitle}_` : ''
1345
1381
  }
1346
- } else if (part.state.title) {
1347
- toolTitle = `*${part.state.title}*`
1382
+ } else if (part.tool === 'edit' || part.tool === 'write') {
1383
+ const filePath = (part.state.input?.filePath as string) || ''
1384
+ const fileName = filePath.split('/').pop() || filePath
1385
+ toolTitle = fileName ? `_${fileName}_` : ''
1386
+ } else if (stateTitle) {
1387
+ toolTitle = `_${stateTitle}_`
1348
1388
  }
1349
1389
 
1350
- const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
1351
- const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1352
-
1353
- if (outputToDisplay) {
1354
- return title + '\n\n' + outputToDisplay
1355
- }
1356
- return title
1390
+ const icon = part.state.status === 'error' ? '⨯' : '◼︎'
1391
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1357
1392
  }
1358
1393
 
1359
1394
  discordLogger.warn('Unknown part type:', part)
@@ -1399,16 +1434,6 @@ async function handleOpencodeSession({
1399
1434
  // Track session start time
1400
1435
  const sessionStartTime = Date.now()
1401
1436
 
1402
- // Add processing reaction to original message
1403
- if (originalMessage) {
1404
- try {
1405
- await originalMessage.react('⏳')
1406
- discordLogger.log(`Added processing reaction to message`)
1407
- } catch (e) {
1408
- discordLogger.log(`Could not add processing reaction:`, e)
1409
- }
1410
- }
1411
-
1412
1437
  // Use default directory if not specified
1413
1438
  const directory = projectDirectory || process.cwd()
1414
1439
  sessionLogger.log(`Using directory: ${directory}`)
@@ -1444,11 +1469,12 @@ async function handleOpencodeSession({
1444
1469
  }
1445
1470
 
1446
1471
  if (!session) {
1472
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
1447
1473
  voiceLogger.log(
1448
- `[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`,
1474
+ `[SESSION] Creating new session with title: "${sessionTitle}"`,
1449
1475
  )
1450
1476
  const sessionResponse = await getClient().session.create({
1451
- body: { title: prompt.slice(0, 80) },
1477
+ body: { title: sessionTitle },
1452
1478
  })
1453
1479
  session = sessionResponse.data
1454
1480
  sessionLogger.log(`Created new session ${session?.id}`)
@@ -1475,46 +1501,46 @@ async function handleOpencodeSession({
1475
1501
  existingController.abort(new Error('New request started'))
1476
1502
  }
1477
1503
 
1478
- if (abortControllers.has(session.id)) {
1479
- abortControllers.get(session.id)?.abort(new Error('new reply'))
1480
- }
1481
1504
  const abortController = new AbortController()
1482
- // Store this controller for this session
1483
1505
  abortControllers.set(session.id, abortController)
1484
1506
 
1507
+ if (existingController) {
1508
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
1509
+ if (abortController.signal.aborted) {
1510
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
1511
+ return
1512
+ }
1513
+ }
1514
+
1515
+ if (abortController.signal.aborted) {
1516
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
1517
+ return
1518
+ }
1519
+
1485
1520
  const eventsResult = await getClient().event.subscribe({
1486
1521
  signal: abortController.signal,
1487
1522
  })
1523
+
1524
+ if (abortController.signal.aborted) {
1525
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
1526
+ return
1527
+ }
1528
+
1488
1529
  const events = eventsResult.stream
1489
1530
  sessionLogger.log(`Subscribed to OpenCode events`)
1490
1531
 
1491
- // Load existing part-message mappings from database
1492
- const partIdToMessage = new Map<string, Message>()
1493
- const existingParts = getDatabase()
1494
- .prepare(
1495
- 'SELECT part_id, message_id FROM part_messages WHERE thread_id = ?',
1496
- )
1497
- .all(thread.id) as { part_id: string; message_id: string }[]
1498
-
1499
- // Pre-populate map with existing messages
1500
- for (const row of existingParts) {
1501
- try {
1502
- const message = await thread.messages.fetch(row.message_id)
1503
- if (message) {
1504
- partIdToMessage.set(row.part_id, message)
1505
- }
1506
- } catch (error) {
1507
- voiceLogger.log(
1508
- `Could not fetch message ${row.message_id} for part ${row.part_id}`,
1509
- )
1510
- }
1511
- }
1532
+ const sentPartIds = new Set<string>(
1533
+ (getDatabase()
1534
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
1535
+ .all(thread.id) as { part_id: string }[])
1536
+ .map((row) => row.part_id)
1537
+ )
1512
1538
 
1513
1539
  let currentParts: Part[] = []
1514
1540
  let stopTyping: (() => void) | null = null
1515
1541
  let usedModel: string | undefined
1516
1542
  let usedProviderID: string | undefined
1517
- let inputTokens = 0
1543
+ let tokensUsedInSession = 0
1518
1544
 
1519
1545
  const sendPartMessage = async (part: Part) => {
1520
1546
  const content = formatPart(part) + '\n\n'
@@ -1524,13 +1550,13 @@ async function handleOpencodeSession({
1524
1550
  }
1525
1551
 
1526
1552
  // Skip if already sent
1527
- if (partIdToMessage.has(part.id)) {
1553
+ if (sentPartIds.has(part.id)) {
1528
1554
  return
1529
1555
  }
1530
1556
 
1531
1557
  try {
1532
1558
  const firstMessage = await sendThreadMessage(thread, content)
1533
- partIdToMessage.set(part.id, firstMessage)
1559
+ sentPartIds.add(part.id)
1534
1560
 
1535
1561
  // Store part-message mapping in database
1536
1562
  getDatabase()
@@ -1603,22 +1629,27 @@ async function handleOpencodeSession({
1603
1629
  if (event.type === 'message.updated') {
1604
1630
  const msg = event.properties.info
1605
1631
 
1632
+
1633
+
1606
1634
  if (msg.sessionID !== session.id) {
1607
1635
  continue
1608
1636
  }
1609
1637
 
1610
1638
  // Track assistant message ID
1611
1639
  if (msg.role === 'assistant') {
1640
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
1641
+ if (newTokensTotal > 0) {
1642
+ tokensUsedInSession = newTokensTotal
1643
+ }
1644
+
1612
1645
  assistantMessageId = msg.id
1613
1646
  usedModel = msg.modelID
1614
1647
  usedProviderID = msg.providerID
1615
- if (msg.tokens.input > 0) {
1616
- inputTokens = msg.tokens.input
1617
- }
1618
1648
  }
1619
1649
  } else if (event.type === 'message.part.updated') {
1620
1650
  const part = event.properties.part
1621
1651
 
1652
+
1622
1653
  if (part.sessionID !== session.id) {
1623
1654
  continue
1624
1655
  }
@@ -1644,13 +1675,19 @@ async function handleOpencodeSession({
1644
1675
  stopTyping = startTyping(thread)
1645
1676
  }
1646
1677
 
1678
+ // Send tool parts immediately when they start running
1679
+ if (part.type === 'tool' && part.state.status === 'running') {
1680
+ await sendPartMessage(part)
1681
+ }
1682
+
1683
+ // Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
1684
+ if (part.type === 'reasoning') {
1685
+ await sendPartMessage(part)
1686
+ }
1687
+
1647
1688
  // Check if this is a step-finish part
1648
1689
  if (part.type === 'step-finish') {
1649
- // Track tokens from step-finish part
1650
- if (part.tokens?.input && part.tokens.input > 0) {
1651
- inputTokens = part.tokens.input
1652
- voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
1653
- }
1690
+
1654
1691
  // Send all parts accumulated so far to Discord
1655
1692
  for (const p of currentParts) {
1656
1693
  // Skip step-start and step-finish parts as they have no visual content
@@ -1752,7 +1789,7 @@ async function handleOpencodeSession({
1752
1789
  } finally {
1753
1790
  // Send any remaining parts that weren't sent
1754
1791
  for (const part of currentParts) {
1755
- if (!partIdToMessage.has(part.id)) {
1792
+ if (!sentPartIds.has(part.id)) {
1756
1793
  try {
1757
1794
  await sendPartMessage(part)
1758
1795
  } catch (error) {
@@ -1778,21 +1815,22 @@ async function handleOpencodeSession({
1778
1815
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1779
1816
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1780
1817
  let contextInfo = ''
1781
- if (inputTokens > 0 && usedProviderID && usedModel) {
1782
- try {
1783
- const providersResponse = await getClient().provider.list({ query: { directory } })
1784
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1785
- const model = provider?.models?.[usedModel]
1786
- if (model?.limit?.context) {
1787
- const percentage = Math.round((inputTokens / model.limit.context) * 100)
1788
- contextInfo = ` ${percentage}%`
1789
- }
1790
- } catch (e) {
1791
- sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1818
+
1819
+
1820
+ try {
1821
+ const providersResponse = await getClient().provider.list({ query: { directory } })
1822
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1823
+ const model = provider?.models?.[usedModel || '']
1824
+ if (model?.limit?.context) {
1825
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
1826
+ contextInfo = ` ⋅ ${percentage}%`
1792
1827
  }
1828
+ } catch (e) {
1829
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1793
1830
  }
1831
+
1794
1832
  await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
1795
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`)
1833
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
1796
1834
  } else {
1797
1835
  sessionLogger.log(
1798
1836
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1802,10 +1840,22 @@ async function handleOpencodeSession({
1802
1840
  }
1803
1841
 
1804
1842
  try {
1805
- // Start the event handler
1806
1843
  const eventHandlerPromise = eventHandler()
1807
1844
 
1808
- let response: { data?: unknown }
1845
+ if (abortController.signal.aborted) {
1846
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
1847
+ return
1848
+ }
1849
+
1850
+ if (originalMessage) {
1851
+ try {
1852
+ await originalMessage.react('⏳')
1853
+ } catch (e) {
1854
+ discordLogger.log(`Could not add processing reaction:`, e)
1855
+ }
1856
+ }
1857
+
1858
+ let response: { data?: unknown; error?: unknown; response: Response }
1809
1859
  if (parsedCommand?.isCommand) {
1810
1860
  sessionLogger.log(
1811
1861
  `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
@@ -1838,18 +1888,33 @@ async function handleOpencodeSession({
1838
1888
  signal: abortController.signal,
1839
1889
  })
1840
1890
  }
1891
+
1892
+ if (response.error) {
1893
+ const errorMessage = (() => {
1894
+ const err = response.error
1895
+ if (err && typeof err === 'object') {
1896
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
1897
+ return String(err.data.message)
1898
+ }
1899
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
1900
+ return JSON.stringify(err.errors)
1901
+ }
1902
+ }
1903
+ return JSON.stringify(err)
1904
+ })()
1905
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1906
+ }
1907
+
1841
1908
  abortController.abort('finished')
1842
1909
 
1843
1910
  sessionLogger.log(`Successfully sent prompt, got response`)
1844
1911
 
1845
- // Update reaction to success
1846
1912
  if (originalMessage) {
1847
1913
  try {
1848
1914
  await originalMessage.reactions.removeAll()
1849
1915
  await originalMessage.react('✅')
1850
- discordLogger.log(`Added success reaction to message`)
1851
1916
  } catch (e) {
1852
- discordLogger.log(`Could not update reaction:`, e)
1917
+ discordLogger.log(`Could not update reactions:`, e)
1853
1918
  }
1854
1919
  }
1855
1920
 
@@ -2091,14 +2156,18 @@ export async function startDiscordBot({
2091
2156
  messageContent = transcription
2092
2157
  }
2093
2158
 
2094
- const images = getImageAttachments(message)
2159
+ const fileAttachments = getFileAttachments(message)
2160
+ const textAttachmentsContent = await getTextAttachments(message)
2161
+ const promptWithAttachments = textAttachmentsContent
2162
+ ? `${messageContent}\n\n${textAttachmentsContent}`
2163
+ : messageContent
2095
2164
  const parsedCommand = parseSlashCommand(messageContent)
2096
2165
  await handleOpencodeSession({
2097
- prompt: messageContent,
2166
+ prompt: promptWithAttachments,
2098
2167
  thread,
2099
2168
  projectDirectory,
2100
2169
  originalMessage: message,
2101
- images,
2170
+ images: fileAttachments,
2102
2171
  parsedCommand,
2103
2172
  })
2104
2173
  return
@@ -2188,14 +2257,18 @@ export async function startDiscordBot({
2188
2257
  messageContent = transcription
2189
2258
  }
2190
2259
 
2191
- const images = getImageAttachments(message)
2260
+ const fileAttachments = getFileAttachments(message)
2261
+ const textAttachmentsContent = await getTextAttachments(message)
2262
+ const promptWithAttachments = textAttachmentsContent
2263
+ ? `${messageContent}\n\n${textAttachmentsContent}`
2264
+ : messageContent
2192
2265
  const parsedCommand = parseSlashCommand(messageContent)
2193
2266
  await handleOpencodeSession({
2194
- prompt: messageContent,
2267
+ prompt: promptWithAttachments,
2195
2268
  thread,
2196
2269
  projectDirectory,
2197
2270
  originalMessage: message,
2198
- images,
2271
+ images: fileAttachments,
2199
2272
  parsedCommand,
2200
2273
  })
2201
2274
  } else {
@@ -2783,6 +2856,94 @@ export async function startDiscordBot({
2783
2856
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2784
2857
  )
2785
2858
  }
2859
+ } else if (command.commandName === 'add-new-project') {
2860
+ await command.deferReply({ ephemeral: false })
2861
+
2862
+ const projectName = command.options.getString('name', true)
2863
+ const guild = command.guild
2864
+ const channel = command.channel
2865
+
2866
+ if (!guild) {
2867
+ await command.editReply('This command can only be used in a guild')
2868
+ return
2869
+ }
2870
+
2871
+ if (!channel || channel.type !== ChannelType.GuildText) {
2872
+ await command.editReply('This command can only be used in a text channel')
2873
+ return
2874
+ }
2875
+
2876
+ const sanitizedName = projectName
2877
+ .toLowerCase()
2878
+ .replace(/[^a-z0-9-]/g, '-')
2879
+ .replace(/-+/g, '-')
2880
+ .replace(/^-|-$/g, '')
2881
+ .slice(0, 100)
2882
+
2883
+ if (!sanitizedName) {
2884
+ await command.editReply('Invalid project name')
2885
+ return
2886
+ }
2887
+
2888
+ const kimakiDir = path.join(os.homedir(), 'kimaki')
2889
+ const projectDirectory = path.join(kimakiDir, sanitizedName)
2890
+
2891
+ try {
2892
+ if (!fs.existsSync(kimakiDir)) {
2893
+ fs.mkdirSync(kimakiDir, { recursive: true })
2894
+ discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
2895
+ }
2896
+
2897
+ if (fs.existsSync(projectDirectory)) {
2898
+ await command.editReply(`Project directory already exists: ${projectDirectory}`)
2899
+ return
2900
+ }
2901
+
2902
+ fs.mkdirSync(projectDirectory, { recursive: true })
2903
+ discordLogger.log(`Created project directory: ${projectDirectory}`)
2904
+
2905
+ const { execSync } = await import('node:child_process')
2906
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
2907
+ discordLogger.log(`Initialized git in: ${projectDirectory}`)
2908
+
2909
+ const { textChannelId, voiceChannelId, channelName } =
2910
+ await createProjectChannels({
2911
+ guild,
2912
+ projectDirectory,
2913
+ appId: currentAppId!,
2914
+ })
2915
+
2916
+ const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
2917
+
2918
+ await command.editReply(
2919
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
2920
+ )
2921
+
2922
+ const starterMessage = await textChannel.send({
2923
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
2924
+ })
2925
+
2926
+ const thread = await starterMessage.startThread({
2927
+ name: `Init: ${sanitizedName}`,
2928
+ autoArchiveDuration: 1440,
2929
+ reason: 'New project session',
2930
+ })
2931
+
2932
+ await handleOpencodeSession({
2933
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
2934
+ thread,
2935
+ projectDirectory,
2936
+ })
2937
+
2938
+ discordLogger.log(
2939
+ `Created new project ${channelName} at ${projectDirectory}`,
2940
+ )
2941
+ } catch (error) {
2942
+ voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
2943
+ await command.editReply(
2944
+ `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
2945
+ )
2946
+ }
2786
2947
  } else if (
2787
2948
  command.commandName === 'accept' ||
2788
2949
  command.commandName === 'accept-always'
@@ -2976,6 +3137,79 @@ export async function startDiscordBot({
2976
3137
  ephemeral: true,
2977
3138
  })
2978
3139
  }
3140
+ } else if (command.commandName === 'share') {
3141
+ const channel = command.channel
3142
+
3143
+ if (!channel) {
3144
+ await command.reply({
3145
+ content: 'This command can only be used in a channel',
3146
+ ephemeral: true,
3147
+ })
3148
+ return
3149
+ }
3150
+
3151
+ const isThread = [
3152
+ ChannelType.PublicThread,
3153
+ ChannelType.PrivateThread,
3154
+ ChannelType.AnnouncementThread,
3155
+ ].includes(channel.type)
3156
+
3157
+ if (!isThread) {
3158
+ await command.reply({
3159
+ content: 'This command can only be used in a thread with an active session',
3160
+ ephemeral: true,
3161
+ })
3162
+ return
3163
+ }
3164
+
3165
+ const textChannel = resolveTextChannel(channel as ThreadChannel)
3166
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3167
+
3168
+ if (!directory) {
3169
+ await command.reply({
3170
+ content: 'Could not determine project directory for this channel',
3171
+ ephemeral: true,
3172
+ })
3173
+ return
3174
+ }
3175
+
3176
+ const row = getDatabase()
3177
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
3178
+ .get(channel.id) as { session_id: string } | undefined
3179
+
3180
+ if (!row?.session_id) {
3181
+ await command.reply({
3182
+ content: 'No active session in this thread',
3183
+ ephemeral: true,
3184
+ })
3185
+ return
3186
+ }
3187
+
3188
+ const sessionId = row.session_id
3189
+
3190
+ try {
3191
+ const getClient = await initializeOpencodeForDirectory(directory)
3192
+ const response = await getClient().session.share({
3193
+ path: { id: sessionId },
3194
+ })
3195
+
3196
+ if (!response.data?.share?.url) {
3197
+ await command.reply({
3198
+ content: 'Failed to generate share URL',
3199
+ ephemeral: true,
3200
+ })
3201
+ return
3202
+ }
3203
+
3204
+ await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
3205
+ sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
3206
+ } catch (error) {
3207
+ voiceLogger.error('[SHARE] Error:', error)
3208
+ await command.reply({
3209
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
3210
+ ephemeral: true,
3211
+ })
3212
+ }
2979
3213
  }
2980
3214
  }
2981
3215
  } catch (error) {
@@ -3261,7 +3495,7 @@ export async function startDiscordBot({
3261
3495
 
3262
3496
  await discordClient.login(token)
3263
3497
 
3264
- const handleShutdown = async (signal: string) => {
3498
+ const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
3265
3499
  discordLogger.log(`Received ${signal}, cleaning up...`)
3266
3500
 
3267
3501
  // Prevent multiple shutdown calls
@@ -3310,11 +3544,15 @@ export async function startDiscordBot({
3310
3544
  discordLogger.log('Destroying Discord client...')
3311
3545
  discordClient.destroy()
3312
3546
 
3313
- discordLogger.log('Cleanup complete, exiting.')
3314
- process.exit(0)
3547
+ discordLogger.log('Cleanup complete.')
3548
+ if (!skipExit) {
3549
+ process.exit(0)
3550
+ }
3315
3551
  } catch (error) {
3316
3552
  voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
3317
- process.exit(1)
3553
+ if (!skipExit) {
3554
+ process.exit(1)
3555
+ }
3318
3556
  }
3319
3557
  }
3320
3558
 
@@ -3337,6 +3575,23 @@ export async function startDiscordBot({
3337
3575
  }
3338
3576
  })
3339
3577
 
3578
+ process.on('SIGUSR2', async () => {
3579
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...')
3580
+ try {
3581
+ await handleShutdown('SIGUSR2', { skipExit: true })
3582
+ } catch (error) {
3583
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
3584
+ }
3585
+ const { spawn } = await import('node:child_process')
3586
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
3587
+ stdio: 'inherit',
3588
+ detached: true,
3589
+ cwd: process.cwd(),
3590
+ env: process.env,
3591
+ }).unref()
3592
+ process.exit(0)
3593
+ })
3594
+
3340
3595
  // Prevent unhandled promise rejections from crashing the process during shutdown
3341
3596
  process.on('unhandledRejection', (reason, promise) => {
3342
3597
  if ((global as any).shuttingDown) {