kimaki 0.4.68 → 0.4.69

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 (91) hide show
  1. package/dist/cli.js +112 -398
  2. package/dist/commands/action-buttons.js +1 -1
  3. package/dist/commands/file-upload.js +6 -9
  4. package/dist/commands/gemini-apikey.js +46 -29
  5. package/dist/commands/login.js +4 -1
  6. package/dist/commands/model.js +5 -7
  7. package/dist/config-lock-port.test.js +17 -0
  8. package/dist/config.js +17 -1
  9. package/dist/database.js +117 -0
  10. package/dist/db.js +54 -17
  11. package/dist/db.test.js +6 -4
  12. package/dist/discord-bot.js +20 -3
  13. package/dist/forum-sync/discord-operations.js +1 -3
  14. package/dist/forum-sync/markdown.js +28 -0
  15. package/dist/forum-sync/sync-to-discord.js +36 -4
  16. package/dist/forum-sync/sync-to-files.js +22 -6
  17. package/dist/generated/enums.js +10 -0
  18. package/dist/generated/internal/class.js +2 -2
  19. package/dist/generated/internal/prismaNamespace.js +14 -1
  20. package/dist/generated/internal/prismaNamespaceBrowser.js +14 -1
  21. package/dist/generated/models/ipc_requests.js +1 -0
  22. package/dist/hrana-server.js +416 -0
  23. package/dist/hrana-server.test.js +368 -0
  24. package/dist/interaction-handler.js +11 -5
  25. package/dist/ipc-polling.js +244 -0
  26. package/dist/kimaki-digital-twin.e2e.test.js +168 -0
  27. package/dist/kimaki-real-discord.e2e.test.js +294 -0
  28. package/dist/logger.js +1 -0
  29. package/dist/markdown.test.js +9 -25
  30. package/dist/opencode-plugin.js +63 -104
  31. package/dist/opencode.js +4 -9
  32. package/dist/session-handler.js +1 -0
  33. package/dist/system-message.js +26 -5
  34. package/dist/voice-handler.js +26 -11
  35. package/dist/voice.js +210 -100
  36. package/dist/voice.test.js +143 -0
  37. package/package.json +9 -3
  38. package/schema.prisma +33 -0
  39. package/skills/errore/SKILL.md +91 -547
  40. package/skills/playwriter/SKILL.md +1 -1
  41. package/skills/termcast/SKILL.md +945 -0
  42. package/skills/tuistory/SKILL.md +6 -9
  43. package/src/__snapshots__/compact-session-context-no-system.md +0 -2
  44. package/src/__snapshots__/compact-session-context.md +0 -2
  45. package/src/__snapshots__/first-session-no-info.md +4 -4150
  46. package/src/__snapshots__/first-session-with-info.md +7 -4153
  47. package/src/__snapshots__/session-1.md +4 -4150
  48. package/src/__snapshots__/session-2.md +5860 -6
  49. package/src/__snapshots__/session-3.md +5 -5624
  50. package/src/__snapshots__/session-with-tools.md +5481 -3773
  51. package/src/cli.ts +141 -497
  52. package/src/commands/action-buttons.ts +1 -1
  53. package/src/commands/file-upload.ts +6 -9
  54. package/src/commands/gemini-apikey.ts +62 -37
  55. package/src/commands/login.ts +13 -10
  56. package/src/commands/model.ts +16 -18
  57. package/src/config.ts +17 -1
  58. package/src/database.ts +151 -0
  59. package/src/db.test.ts +8 -4
  60. package/src/db.ts +61 -22
  61. package/src/discord-bot.ts +26 -2
  62. package/src/forum-sync/discord-operations.ts +8 -10
  63. package/src/forum-sync/markdown.ts +43 -0
  64. package/src/forum-sync/sync-to-discord.ts +49 -4
  65. package/src/forum-sync/sync-to-files.ts +24 -6
  66. package/src/generated/browser.ts +5 -0
  67. package/src/generated/client.ts +5 -0
  68. package/src/generated/commonInputTypes.ts +68 -0
  69. package/src/generated/enums.ts +18 -0
  70. package/src/generated/internal/class.ts +12 -2
  71. package/src/generated/internal/prismaNamespace.ts +108 -2
  72. package/src/generated/internal/prismaNamespaceBrowser.ts +18 -1
  73. package/src/generated/models/bot_api_keys.ts +33 -1
  74. package/src/generated/models/ipc_requests.ts +1485 -0
  75. package/src/generated/models/thread_sessions.ts +122 -0
  76. package/src/generated/models.ts +1 -0
  77. package/src/hrana-server.test.ts +428 -0
  78. package/src/hrana-server.ts +547 -0
  79. package/src/interaction-handler.ts +14 -6
  80. package/src/ipc-polling.ts +318 -0
  81. package/src/kimaki-digital-twin.e2e.test.ts +204 -0
  82. package/src/logger.ts +1 -0
  83. package/src/markdown.test.ts +9 -27
  84. package/src/opencode-plugin.ts +67 -133
  85. package/src/opencode.ts +4 -9
  86. package/src/schema.sql +14 -0
  87. package/src/session-handler.ts +4 -1
  88. package/src/system-message.ts +28 -4
  89. package/src/voice-handler.ts +25 -10
  90. package/src/voice.test.ts +173 -0
  91. package/src/voice.ts +294 -131
package/dist/cli.js CHANGED
@@ -18,15 +18,14 @@ import fs from 'node:fs';
18
18
  import * as errore from 'errore';
19
19
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
20
20
  import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
21
- import { spawn, spawnSync, execSync, } from 'node:child_process';
22
- import http from 'node:http';
23
- import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, setDefaultMentionMode, setCritiqueEnabled, setVerboseOpencodeServer, getMemoryEnabled, setMemoryEnabled, getProjectsDir, } from './config.js';
21
+ import { spawn, execSync } from 'node:child_process';
22
+ import { setDataDir, getDataDir, setDefaultVerbosity, setDefaultMentionMode, setCritiqueEnabled, setVerboseOpencodeServer, getMemoryEnabled, setMemoryEnabled, getProjectsDir, } from './config.js';
24
23
  import { sanitizeAgentName } from './commands/agent.js';
25
- import { showFileUploadButton, } from './commands/file-upload.js';
26
- import { queueActionButtonsRequest, } from './commands/action-buttons.js';
27
24
  import { execAsync } from './worktree-utils.js';
28
25
  import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
29
26
  import { startConfiguredForumSync } from './forum-sync/index.js';
27
+ import { startHranaServer } from './hrana-server.js';
28
+ import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
30
29
  import { getLocalTimeZone, getPromptPreview, parseSendAtValue, serializeScheduledTaskPayload, } from './task-schedule.js';
31
30
  const cliLogger = createLogger(LogPrefix.CLI);
32
31
  // Strip bracketed paste escape sequences from terminal input.
@@ -41,6 +40,46 @@ function stripBracketedPaste(value) {
41
40
  .replace(/\x1b\[201~/g, '')
42
41
  .trim();
43
42
  }
43
+ // Derive the Discord Application ID from a bot token.
44
+ // Discord bot tokens have the format: base64(userId).timestamp.hmac
45
+ // The first segment is the bot's user ID (= Application ID) base64-encoded.
46
+ function appIdFromToken(token) {
47
+ const segment = token.split('.')[0];
48
+ if (!segment) {
49
+ return undefined;
50
+ }
51
+ try {
52
+ const decoded = Buffer.from(segment, 'base64').toString('utf8');
53
+ if (/^\d{17,20}$/.test(decoded)) {
54
+ return decoded;
55
+ }
56
+ return undefined;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ // Resolve bot token and app ID from env var or database.
63
+ // Used by CLI subcommands (send, project add) that need credentials
64
+ // but don't run the interactive wizard.
65
+ async function resolveBotCredentials({ appIdOverride } = {}) {
66
+ const envToken = process.env.KIMAKI_BOT_TOKEN;
67
+ if (envToken) {
68
+ // Prefer token-derived appId over stale DB values when using env token,
69
+ // since the DB may have credentials from a different bot.
70
+ const appId = appIdOverride || appIdFromToken(envToken);
71
+ return { token: envToken, appId };
72
+ }
73
+ const botRow = await getBotToken().catch((e) => {
74
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
75
+ return null;
76
+ });
77
+ if (!botRow) {
78
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
79
+ process.exit(EXIT_NO_RESTART);
80
+ }
81
+ return { token: botRow.token, appId: appIdOverride || botRow.app_id };
82
+ }
44
83
  function isThreadChannelType(type) {
45
84
  return [
46
85
  ChannelType.PublicThread,
@@ -213,17 +252,6 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
213
252
  process.env[envPathKey] = installedPath;
214
253
  }
215
254
  // Run opencode upgrade in the background so the user always has the latest version.
216
- // Fire-and-forget: errors are silently ignored since this is non-critical.
217
- function backgroundUpgradeOpencode() {
218
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
219
- const child = spawn(opencodeCommand, ['upgrade'], {
220
- shell: true,
221
- stdio: 'ignore',
222
- detached: true,
223
- });
224
- child.unref();
225
- cliLogger.debug('Started background opencode upgrade');
226
- }
227
255
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
228
256
  // Not detached, so it dies automatically with the parent process.
229
257
  function startCaffeinate() {
@@ -246,274 +274,6 @@ function startCaffeinate() {
246
274
  }
247
275
  const cli = goke('kimaki');
248
276
  process.title = 'kimaki';
249
- async function killProcessOnPort(port) {
250
- const isWindows = process.platform === 'win32';
251
- const myPid = process.pid;
252
- try {
253
- if (isWindows) {
254
- // Windows: find PID using netstat, then kill
255
- const result = spawnSync('cmd', [
256
- '/c',
257
- `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
258
- ], {
259
- shell: false,
260
- encoding: 'utf-8',
261
- });
262
- const pids = result.stdout
263
- ?.trim()
264
- .split('\n')
265
- .map((p) => p.trim())
266
- .filter((p) => /^\d+$/.test(p));
267
- // Filter out our own PID and take the first (oldest)
268
- const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
269
- if (targetPid) {
270
- cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
271
- spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false });
272
- return true;
273
- }
274
- }
275
- else {
276
- // Unix: use lsof with -sTCP:LISTEN to only find the listening process
277
- const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
278
- shell: false,
279
- encoding: 'utf-8',
280
- });
281
- const pids = result.stdout
282
- ?.trim()
283
- .split('\n')
284
- .map((p) => p.trim())
285
- .filter((p) => /^\d+$/.test(p));
286
- // Filter out our own PID and take the first (oldest)
287
- const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
288
- if (targetPid) {
289
- const pid = parseInt(targetPid, 10);
290
- cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`);
291
- process.kill(pid, 'SIGKILL');
292
- return true;
293
- }
294
- }
295
- }
296
- catch (e) {
297
- cliLogger.debug(`Failed to kill process on port ${port}:`, e);
298
- }
299
- return false;
300
- }
301
- async function checkSingleInstance() {
302
- const lockPort = getLockPort();
303
- try {
304
- const response = await fetch(`http://127.0.0.1:${lockPort}`, {
305
- signal: AbortSignal.timeout(1000),
306
- });
307
- if (response.ok) {
308
- cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
309
- await killProcessOnPort(lockPort);
310
- // Wait a moment for port to be released
311
- await new Promise((resolve) => {
312
- setTimeout(resolve, 500);
313
- });
314
- }
315
- }
316
- catch (error) {
317
- cliLogger.debug('Lock port check failed:', error instanceof Error ? error.message : String(error));
318
- cliLogger.debug('No other kimaki instance detected on lock port');
319
- }
320
- }
321
- // Set after Discord login. Used by the lock server /file-upload route.
322
- let discordClientRef = null;
323
- async function startLockServer() {
324
- const lockPort = getLockPort();
325
- return new Promise((resolve, reject) => {
326
- const server = http.createServer(async (req, res) => {
327
- // POST /file-upload - handle file upload requests from the opencode plugin
328
- if (req.method === 'POST' && req.url === '/file-upload') {
329
- if (!discordClientRef) {
330
- res.writeHead(503, { 'Content-Type': 'application/json' });
331
- res.end(JSON.stringify({ error: 'Discord client not ready' }));
332
- return;
333
- }
334
- let body = '';
335
- req.on('data', (chunk) => {
336
- body += chunk.toString();
337
- });
338
- // Track if the client (plugin) disconnected so we don't write to closed socket.
339
- // Use res.on('close') not req.on('close') because req 'close' fires after body
340
- // is received (normal flow), while res 'close' fires on socket teardown.
341
- let clientDisconnected = false;
342
- res.on('close', () => {
343
- if (!res.writableFinished) {
344
- clientDisconnected = true;
345
- }
346
- });
347
- req.on('end', async () => {
348
- try {
349
- const parsed = JSON.parse(body);
350
- // Validate required fields
351
- const request = {
352
- sessionId: String(parsed.sessionId || ''),
353
- threadId: String(parsed.threadId || ''),
354
- directory: String(parsed.directory || ''),
355
- prompt: String(parsed.prompt || 'Please upload files'),
356
- maxFiles: Math.min(10, Math.max(1, Number(parsed.maxFiles) || 5)),
357
- };
358
- if (!request.sessionId || !request.threadId || !request.directory) {
359
- res.writeHead(400, { 'Content-Type': 'application/json' });
360
- res.end(JSON.stringify({
361
- error: 'Missing required fields: sessionId, threadId, directory',
362
- }));
363
- return;
364
- }
365
- const thread = await discordClientRef.channels.fetch(request.threadId);
366
- if (!thread || !thread.isThread()) {
367
- res.writeHead(404, { 'Content-Type': 'application/json' });
368
- res.end(JSON.stringify({ error: 'Thread not found' }));
369
- return;
370
- }
371
- const filePaths = await showFileUploadButton({
372
- thread,
373
- sessionId: request.sessionId,
374
- directory: request.directory,
375
- prompt: request.prompt,
376
- maxFiles: request.maxFiles,
377
- });
378
- if (clientDisconnected) {
379
- return;
380
- }
381
- res.writeHead(200, { 'Content-Type': 'application/json' });
382
- res.end(JSON.stringify({ filePaths }));
383
- }
384
- catch (err) {
385
- const message = err instanceof Error ? err.message : String(err);
386
- cliLogger.error('[FILE-UPLOAD] Error handling request:', message);
387
- if (clientDisconnected) {
388
- return;
389
- }
390
- res.writeHead(500, { 'Content-Type': 'application/json' });
391
- res.end(JSON.stringify({ error: message }));
392
- }
393
- });
394
- return;
395
- }
396
- // POST /action-buttons - queue action buttons for session-handler to render
397
- if (req.method === 'POST' && req.url === '/action-buttons') {
398
- if (!discordClientRef) {
399
- res.writeHead(503, { 'Content-Type': 'application/json' });
400
- res.end(JSON.stringify({ error: 'Discord client not ready' }));
401
- return;
402
- }
403
- let body = '';
404
- req.on('data', (chunk) => {
405
- body += chunk.toString();
406
- });
407
- let clientDisconnected = false;
408
- res.on('close', () => {
409
- if (!res.writableFinished) {
410
- clientDisconnected = true;
411
- }
412
- });
413
- req.on('end', async () => {
414
- try {
415
- const parsed = JSON.parse(body);
416
- const parsedButtons = Array.isArray(parsed.buttons)
417
- ? parsed.buttons
418
- .map((value) => {
419
- if (!value || typeof value !== 'object') {
420
- return null;
421
- }
422
- const maybeLabel = 'label' in value && typeof value.label === 'string'
423
- ? value.label
424
- : '';
425
- const maybeColor = 'color' in value && typeof value.color === 'string'
426
- ? value.color
427
- : undefined;
428
- const safeColor = maybeColor === 'white' ||
429
- maybeColor === 'blue' ||
430
- maybeColor === 'green' ||
431
- maybeColor === 'red'
432
- ? maybeColor
433
- : undefined;
434
- const label = maybeLabel.trim().slice(0, 80);
435
- if (!label) {
436
- return null;
437
- }
438
- return {
439
- label,
440
- color: safeColor,
441
- };
442
- })
443
- .filter((value) => {
444
- return value !== null;
445
- })
446
- .slice(0, 3)
447
- : [];
448
- const request = {
449
- sessionId: typeof parsed.sessionId === 'string' ? parsed.sessionId : '',
450
- threadId: typeof parsed.threadId === 'string' ? parsed.threadId : '',
451
- directory: typeof parsed.directory === 'string' ? parsed.directory : '',
452
- buttons: parsedButtons,
453
- };
454
- if (!request.sessionId || !request.threadId || !request.directory) {
455
- res.writeHead(400, { 'Content-Type': 'application/json' });
456
- res.end(JSON.stringify({
457
- error: 'Missing required fields: sessionId, threadId, directory',
458
- }));
459
- return;
460
- }
461
- if (request.buttons.length === 0) {
462
- res.writeHead(400, { 'Content-Type': 'application/json' });
463
- res.end(JSON.stringify({
464
- error: 'At least one valid button is required',
465
- }));
466
- return;
467
- }
468
- const thread = await discordClientRef.channels.fetch(request.threadId);
469
- if (!thread || !thread.isThread()) {
470
- res.writeHead(404, { 'Content-Type': 'application/json' });
471
- res.end(JSON.stringify({ error: 'Thread not found' }));
472
- return;
473
- }
474
- queueActionButtonsRequest(request);
475
- if (clientDisconnected) {
476
- return;
477
- }
478
- res.writeHead(200, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ ok: true }));
480
- }
481
- catch (err) {
482
- const message = err instanceof Error ? err.message : String(err);
483
- cliLogger.error('[ACTION-BUTTONS] Error handling request:', message);
484
- if (clientDisconnected) {
485
- return;
486
- }
487
- res.writeHead(500, { 'Content-Type': 'application/json' });
488
- res.end(JSON.stringify({ error: message }));
489
- }
490
- });
491
- return;
492
- }
493
- res.writeHead(200);
494
- res.end('kimaki');
495
- });
496
- server.listen(lockPort, '127.0.0.1');
497
- server.once('listening', () => {
498
- cliLogger.debug(`Lock server started on port ${lockPort}`);
499
- resolve();
500
- });
501
- server.on('error', async (err) => {
502
- if (err.code === 'EADDRINUSE') {
503
- cliLogger.log('Port still in use, retrying...');
504
- await killProcessOnPort(lockPort);
505
- await new Promise((r) => {
506
- setTimeout(r, 500);
507
- });
508
- // Retry once
509
- server.listen(lockPort, '127.0.0.1');
510
- }
511
- else {
512
- reject(err);
513
- }
514
- });
515
- });
516
- }
517
277
  // Commands to skip when registering user commands (reserved names)
518
278
  const SKIP_USER_COMMANDS = ['init'];
519
279
  import { registeredUserCommands } from './config.js';
@@ -769,6 +529,11 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
769
529
  .setDescription('Upgrade kimaki to the latest version and restart the bot')
770
530
  .setDMPermission(false)
771
531
  .toJSON(),
532
+ new SlashCommandBuilder()
533
+ .setName('transcription-key')
534
+ .setDescription('Set API key for voice message transcription (OpenAI or Gemini)')
535
+ .setDMPermission(false)
536
+ .toJSON(),
772
537
  ];
773
538
  // Add user-defined commands with -cmd suffix
774
539
  // Also populate registeredUserCommands for /queue-command autocomplete
@@ -1020,47 +785,52 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels, })
1020
785
  possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1021
786
  possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1022
787
  });
1023
- backgroundUpgradeOpencode();
1024
788
  backgroundUpgradeKimaki();
1025
- // Initialize database
1026
- await initDatabase();
1027
- let appId;
1028
- let token;
1029
- const existingBot = await getBotToken();
1030
- const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels);
1031
- const isQuickStart = Boolean(existingBot && !forceSetup && !addChannels);
1032
- if (existingBot && !forceSetup) {
1033
- appId = existingBot.app_id;
1034
- token = existingBot.token;
1035
- note(`Using saved bot credentials:\nApp ID: ${appId}\n\nTo use different credentials, run with --restart`, 'Existing Bot Found');
1036
- note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: appId })}`, 'Install URL');
789
+ // Start in-process Hrana server before database init. Required for the bot
790
+ // process because it serves as both the DB server and the single-instance
791
+ // lock (binds the fixed lock port). Without it, IPC and lock enforcement
792
+ // don't work. CLI subcommands skip the server and use file: directly.
793
+ const hranaResult = await startHranaServer({
794
+ dbPath: path.join(getDataDir(), 'discord-sessions.db'),
795
+ });
796
+ if (hranaResult instanceof Error) {
797
+ cliLogger.error('Failed to start hrana server:', hranaResult.message);
798
+ process.exit(EXIT_NO_RESTART);
1037
799
  }
1038
- else {
800
+ // Initialize database (connects to hrana server via HTTP)
801
+ await initDatabase();
802
+ // Resolve bot credentials from (in priority order):
803
+ // 1. KIMAKI_BOT_TOKEN env var (headless/CI deployments)
804
+ // 2. Saved credentials in the database
805
+ // 3. Interactive setup wizard (first-time users)
806
+ // App ID is always derived from the token (base64 first segment).
807
+ const { appId, token, isQuickStart } = await (async () => {
808
+ const envToken = process.env.KIMAKI_BOT_TOKEN;
809
+ const existingBot = await getBotToken();
810
+ // 1. Env var takes precedence (headless deployments)
811
+ if (envToken && !forceSetup) {
812
+ const derivedAppId = appIdFromToken(envToken);
813
+ if (!derivedAppId) {
814
+ cliLogger.error('Could not derive Application ID from KIMAKI_BOT_TOKEN. The token appears malformed.');
815
+ process.exit(EXIT_NO_RESTART);
816
+ }
817
+ await setBotToken(derivedAppId, envToken);
818
+ cliLogger.log(`Using KIMAKI_BOT_TOKEN env var (App ID: ${derivedAppId})`);
819
+ return { appId: derivedAppId, token: envToken, isQuickStart: !addChannels };
820
+ }
821
+ // 2. Saved credentials in the database
822
+ if (existingBot && !forceSetup) {
823
+ note(`Using saved bot credentials:\nApp ID: ${existingBot.app_id}\n\nTo use different credentials, run with --restart`, 'Existing Bot Found');
824
+ note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: existingBot.app_id })}`, 'Install URL');
825
+ return { appId: existingBot.app_id, token: existingBot.token, isQuickStart: !addChannels };
826
+ }
827
+ // 3. Interactive setup wizard
1039
828
  if (forceSetup && existingBot) {
1040
829
  note('Ignoring saved credentials due to --restart flag', 'Restart Setup');
1041
830
  }
1042
831
  note('1. Go to https://discord.com/developers/applications\n' +
1043
832
  '2. Click "New Application"\n' +
1044
- '3. Give your application a name\n' +
1045
- '4. Copy the Application ID from the "General Information" section', 'Step 1: Create Discord Application');
1046
- const appIdInput = await text({
1047
- message: 'Enter your Discord Application ID:',
1048
- placeholder: 'e.g., 1234567890123456789',
1049
- validate(value) {
1050
- const cleaned = stripBracketedPaste(value);
1051
- if (!cleaned) {
1052
- return 'Application ID is required';
1053
- }
1054
- if (!/^\d{17,20}$/.test(cleaned)) {
1055
- return 'Invalid Application ID format (should be 17-20 digits)';
1056
- }
1057
- },
1058
- });
1059
- if (isCancel(appIdInput)) {
1060
- cancel('Setup cancelled');
1061
- process.exit(0);
1062
- }
1063
- appId = stripBracketedPaste(appIdInput);
833
+ '3. Give your application a name', 'Step 1: Create Discord Application');
1064
834
  note('1. Go to the "Bot" section in the left sidebar\n' +
1065
835
  '2. Scroll down to "Privileged Gateway Intents"\n' +
1066
836
  '3. Enable these intents by toggling them ON:\n' +
@@ -1094,9 +864,14 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels, })
1094
864
  cancel('Setup cancelled');
1095
865
  process.exit(0);
1096
866
  }
1097
- token = stripBracketedPaste(tokenInput);
1098
- await setBotToken(appId, token);
1099
- note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
867
+ const wizardToken = stripBracketedPaste(tokenInput);
868
+ const derivedAppId = appIdFromToken(wizardToken);
869
+ if (!derivedAppId) {
870
+ cliLogger.error('Could not derive Application ID from the bot token. The token appears malformed.');
871
+ process.exit(EXIT_NO_RESTART);
872
+ }
873
+ await setBotToken(derivedAppId, wizardToken);
874
+ note(`Bot install URL:\n${generateBotInstallUrl({ clientId: derivedAppId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
1100
875
  const installed = await text({
1101
876
  message: 'Press Enter AFTER you have installed the bot in your server:',
1102
877
  placeholder: 'Enter',
@@ -1105,7 +880,9 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels, })
1105
880
  cancel('Setup cancelled');
1106
881
  process.exit(0);
1107
882
  }
1108
- }
883
+ return { appId: derivedAppId, token: wizardToken, isQuickStart: false };
884
+ })();
885
+ const shouldAddChannels = !isQuickStart || forceSetup || Boolean(addChannels);
1109
886
  // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
1110
887
  // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
1111
888
  const currentDir = process.cwd();
@@ -1145,7 +922,10 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels, })
1145
922
  discordClient.login(token).catch(reject);
1146
923
  });
1147
924
  cliLogger.log('Connected to Discord!');
1148
- discordClientRef = discordClient;
925
+ // Start IPC polling now that Discord client is ready.
926
+ // Register cleanup on process exit since the shutdown handler lives in discord-bot.ts.
927
+ await startIpcPolling({ discordClient });
928
+ process.on('exit', stopIpcPolling);
1149
929
  }
1150
930
  catch (error) {
1151
931
  cliLogger.log('Failed to connect to Discord');
@@ -1435,8 +1215,8 @@ cli
1435
1215
  cliLogger.log(generateBotInstallUrl({ clientId: existingBot.app_id }));
1436
1216
  process.exit(0);
1437
1217
  }
1438
- await checkSingleInstance();
1439
- await startLockServer();
1218
+ // Single-instance enforcement is handled by the hrana server binding the lock port.
1219
+ // startHranaServer() in run() evicts any existing instance before binding.
1440
1220
  await run({
1441
1221
  restart: options.restart,
1442
1222
  addChannels: options.addChannels,
@@ -1596,43 +1376,11 @@ cli
1596
1376
  process.exit(EXIT_NO_RESTART);
1597
1377
  }
1598
1378
  }
1599
- // Get bot token from env var or database
1600
- const envToken = process.env.KIMAKI_BOT_TOKEN;
1601
- let botToken;
1602
- let appId = optionAppId;
1603
1379
  // Initialize database first
1604
1380
  await initDatabase();
1605
- if (envToken) {
1606
- botToken = envToken;
1607
- if (!appId) {
1608
- // Try to get app_id from database if available (optional in CI)
1609
- try {
1610
- const botRow = await getBotToken();
1611
- appId = botRow?.app_id;
1612
- }
1613
- catch (error) {
1614
- cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
1615
- }
1616
- }
1617
- }
1618
- else {
1619
- // Fall back to database
1620
- try {
1621
- const botRow = await getBotToken();
1622
- if (botRow) {
1623
- botToken = botRow.token;
1624
- appId = appId || botRow.app_id;
1625
- }
1626
- }
1627
- catch (e) {
1628
- // Database error - will fall through to the check below
1629
- cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
1630
- }
1631
- }
1632
- if (!botToken) {
1633
- cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
1634
- process.exit(EXIT_NO_RESTART);
1635
- }
1381
+ const { token: botToken, appId } = await resolveBotCredentials({
1382
+ appIdOverride: optionAppId,
1383
+ });
1636
1384
  // If --project provided (or defaulting to cwd), resolve to channel ID
1637
1385
  if (resolvedProjectPath) {
1638
1386
  const absolutePath = path.resolve(resolvedProjectPath);
@@ -2049,38 +1797,9 @@ cli
2049
1797
  }
2050
1798
  // Initialize database
2051
1799
  await initDatabase();
2052
- // Get bot token from env var or database
2053
- const envToken = process.env.KIMAKI_BOT_TOKEN;
2054
- let botToken;
2055
- let appId = options.appId;
2056
- if (envToken) {
2057
- botToken = envToken;
2058
- if (!appId) {
2059
- try {
2060
- const botRow = await getBotToken();
2061
- appId = botRow?.app_id;
2062
- }
2063
- catch (error) {
2064
- cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
2065
- }
2066
- }
2067
- }
2068
- else {
2069
- try {
2070
- const botRow = await getBotToken();
2071
- if (botRow) {
2072
- botToken = botRow.token;
2073
- appId = appId || botRow.app_id;
2074
- }
2075
- }
2076
- catch (e) {
2077
- cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
2078
- }
2079
- }
2080
- if (!botToken) {
2081
- cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
2082
- process.exit(EXIT_NO_RESTART);
2083
- }
1800
+ const { token: botToken, appId } = await resolveBotCredentials({
1801
+ appIdOverride: options.appId,
1802
+ });
2084
1803
  if (!appId) {
2085
1804
  cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
2086
1805
  process.exit(EXIT_NO_RESTART);
@@ -2724,12 +2443,7 @@ cli
2724
2443
  .action(async (threadId) => {
2725
2444
  try {
2726
2445
  await initDatabase();
2727
- const envToken = process.env.KIMAKI_BOT_TOKEN;
2728
- const botToken = envToken || (await getBotToken())?.token;
2729
- if (!botToken) {
2730
- cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
2731
- process.exit(EXIT_NO_RESTART);
2732
- }
2446
+ const { token: botToken } = await resolveBotCredentials();
2733
2447
  const rest = new REST().setToken(botToken);
2734
2448
  const threadData = (await rest.get(Routes.channel(threadId)));
2735
2449
  if (!isThreadChannelType(threadData.type)) {
@@ -8,7 +8,7 @@ import { NOTIFY_MESSAGE_FLAGS, resolveWorkingDirectory, sendThreadMessage, } fro
8
8
  import { createLogger } from '../logger.js';
9
9
  import { abortControllers, addToQueue, handleOpencodeSession, } from '../session-handler.js';
10
10
  const logger = createLogger('ACT_BTN');
11
- const PENDING_TTL_MS = 30 * 60 * 1000;
11
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
12
12
  export const pendingActionButtonContexts = new Map();
13
13
  const pendingActionButtonRequests = new Map();
14
14
  const pendingActionButtonRequestWaiters = new Map();
@@ -1,13 +1,10 @@
1
1
  // File upload tool handler - Shows Discord modal with FileUploadBuilder.
2
- // When the AI uses the kimaki_file_upload tool, the plugin POSTs to the bot's
3
- // lock server HTTP endpoint. The bot shows a button in the thread, user clicks
4
- // it to open a modal with a native file picker. Uploaded files are downloaded
5
- // to the project directory and paths returned to the AI.
6
- //
7
- // Architecture: The plugin tool (running in OpenCode's process) communicates
8
- // with the Discord bot via HTTP. The bot holds the HTTP response open until
9
- // the user completes the upload or the request is cancelled. This bridges the
10
- // gap between the plugin process and Discord's interaction-based UI.
2
+ // When the AI uses the kimaki_file_upload tool, the plugin inserts a row into
3
+ // the ipc_requests DB table. The bot polls this table, picks up the request,
4
+ // and shows a button in the thread. User clicks it to open a modal with a
5
+ // native file picker. Uploaded files are downloaded to the project directory.
6
+ // The bot writes file paths back to ipc_requests.response, and the plugin
7
+ // polls until the response appears.
11
8
  import { ButtonBuilder, ButtonStyle, ActionRowBuilder, ModalBuilder, FileUploadBuilder, LabelBuilder, ComponentType, MessageFlags, } from 'discord.js';
12
9
  import crypto from 'node:crypto';
13
10
  import fs from 'node:fs';