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/dist/cli.js CHANGED
@@ -3,10 +3,11 @@
3
3
  // Handles interactive setup, Discord OAuth, slash command registration,
4
4
  // project channel creation, and launching the bot with opencode integration.
5
5
  import { goke } from 'goke';
6
+ import { z } from 'zod';
6
7
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
7
8
  import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
8
9
  import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
9
- import { getBotTokenWithMode, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getSessionStartSourcesBySessionIds, } from './database.js';
10
+ import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, } from './database.js';
10
11
  import { ShareMarkdown } from './markdown.js';
11
12
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
12
13
  import { formatWorktreeName } from './commands/new-worktree.js';
@@ -16,7 +17,7 @@ import { buildOpencodeEventLogLine } from './session-handler/opencode-session-ev
16
17
  import { selectResolvedCommand } from './opencode-command.js';
17
18
  import yaml from 'js-yaml';
18
19
  import { Events, ChannelType, ActivityType, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
19
- import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl } from './discord-urls.js';
20
+ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
20
21
  import crypto from 'node:crypto';
21
22
  import path from 'node:path';
22
23
  import fs from 'node:fs';
@@ -31,7 +32,7 @@ import { execAsync } from './worktrees.js';
31
32
  import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
32
33
  import { startHranaServer } from './hrana-server.js';
33
34
  import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
34
- import { getLocalTimeZone, getPromptPreview, parseSendAtValue, serializeScheduledTaskPayload, } from './task-schedule.js';
35
+ import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
35
36
  const cliLogger = createLogger(LogPrefix.CLI);
36
37
  // Gateway bot mode constants.
37
38
  // KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
@@ -128,7 +129,37 @@ async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, bot
128
129
  fs.mkdirSync(tmpDir, { recursive: true });
129
130
  }
130
131
  const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
131
- fs.writeFileSync(tmpFile, prompt);
132
+ // Wrap long lines so the file is readable in Discord's preview
133
+ // (Discord doesn't wrap text in file attachments)
134
+ const wrappedPrompt = prompt
135
+ .split('\n')
136
+ .flatMap((line) => {
137
+ if (line.length <= 120) {
138
+ return [line];
139
+ }
140
+ const wrapped = [];
141
+ let remaining = line;
142
+ const maxCol = 120;
143
+ // Only soft-break at a space if it's reasonably close to maxCol,
144
+ // otherwise hard-break to avoid tiny fragments from early spaces
145
+ const minSoftBreak = 90;
146
+ while (remaining.length > maxCol) {
147
+ const lastSpace = remaining.lastIndexOf(' ', maxCol);
148
+ const useSoftBreak = lastSpace >= minSoftBreak;
149
+ const breakAt = useSoftBreak ? lastSpace : maxCol;
150
+ wrapped.push(remaining.slice(0, breakAt));
151
+ // Only consume the separator space on soft breaks
152
+ remaining = useSoftBreak
153
+ ? remaining.slice(breakAt + 1)
154
+ : remaining.slice(breakAt);
155
+ }
156
+ if (remaining.length > 0) {
157
+ wrapped.push(remaining);
158
+ }
159
+ return wrapped;
160
+ })
161
+ .join('\n');
162
+ fs.writeFileSync(tmpFile, wrappedPrompt);
132
163
  try {
133
164
  const formData = new FormData();
134
165
  formData.append('payload_json', JSON.stringify({
@@ -336,7 +367,7 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
336
367
  }
337
368
  catch (error) {
338
369
  cliLogger.log(`Failed to install ${name}`);
339
- cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error));
370
+ cliLogger.error('Installation error:', error instanceof Error ? error.stack : String(error));
340
371
  process.exit(EXIT_NO_RESTART);
341
372
  }
342
373
  // After install, re-check PATH first (install script may have added it)
@@ -383,16 +414,18 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
383
414
  }
384
415
  // Run opencode upgrade in the background so the user always has the latest version.
385
416
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
386
- // Not detached, so it dies automatically with the parent process.
417
+ // Uses -w to watch the parent PID so caffeinate self-terminates if kimaki
418
+ // exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
387
419
  function startCaffeinate() {
388
420
  if (process.platform !== 'darwin') {
389
421
  return;
390
422
  }
391
423
  try {
392
- const proc = spawn('caffeinate', ['-i'], {
424
+ const proc = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
393
425
  stdio: 'ignore',
394
426
  detached: false,
395
427
  });
428
+ proc.unref();
396
429
  proc.on('error', (err) => {
397
430
  cliLogger.warn('Failed to start caffeinate:', err.message);
398
431
  });
@@ -455,18 +488,23 @@ async function deleteLegacyGlobalCommands({ rest, appId, commandNames, }) {
455
488
  }
456
489
  }
457
490
  catch (error) {
458
- cliLogger.warn(`COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.message : String(error)}`);
491
+ cliLogger.warn(`COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.stack : String(error)}`);
459
492
  }
460
493
  }
494
+ // Discord slash command descriptions must be 1-100 chars.
495
+ // Truncate to 100 so @sapphire/shapeshift validation never throws.
496
+ function truncateCommandDescription(description) {
497
+ return description.slice(0, 100);
498
+ }
461
499
  async function registerCommands({ token, appId, guildIds, userCommands = [], agents = [], }) {
462
500
  const commands = [
463
501
  new SlashCommandBuilder()
464
502
  .setName('resume')
465
- .setDescription('Resume an existing OpenCode session')
503
+ .setDescription(truncateCommandDescription('Resume an existing OpenCode session'))
466
504
  .addStringOption((option) => {
467
505
  option
468
506
  .setName('session')
469
- .setDescription('The session to resume')
507
+ .setDescription(truncateCommandDescription('The session to resume'))
470
508
  .setRequired(true)
471
509
  .setAutocomplete(true);
472
510
  return option;
@@ -475,18 +513,18 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
475
513
  .toJSON(),
476
514
  new SlashCommandBuilder()
477
515
  .setName('new-session')
478
- .setDescription('Start a new OpenCode session')
516
+ .setDescription(truncateCommandDescription('Start a new OpenCode session'))
479
517
  .addStringOption((option) => {
480
518
  option
481
519
  .setName('prompt')
482
- .setDescription('Prompt content for the session')
520
+ .setDescription(truncateCommandDescription('Prompt content for the session'))
483
521
  .setRequired(true);
484
522
  return option;
485
523
  })
486
524
  .addStringOption((option) => {
487
525
  option
488
526
  .setName('files')
489
- .setDescription('Files to mention (comma or space separated; autocomplete)')
527
+ .setDescription(truncateCommandDescription('Files to mention (comma or space separated; autocomplete)'))
490
528
  .setAutocomplete(true)
491
529
  .setMaxLength(6000);
492
530
  return option;
@@ -494,7 +532,7 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
494
532
  .addStringOption((option) => {
495
533
  option
496
534
  .setName('agent')
497
- .setDescription('Agent to use for this session')
535
+ .setDescription(truncateCommandDescription('Agent to use for this session'))
498
536
  .setAutocomplete(true);
499
537
  return option;
500
538
  })
@@ -502,18 +540,18 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
502
540
  .toJSON(),
503
541
  new SlashCommandBuilder()
504
542
  .setName('new-worktree')
505
- .setDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.')
543
+ .setDescription(truncateCommandDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.'))
506
544
  .addStringOption((option) => {
507
545
  option
508
546
  .setName('name')
509
- .setDescription('Name for worktree (optional in threads - uses thread name)')
547
+ .setDescription(truncateCommandDescription('Name for worktree (optional in threads - uses thread name)'))
510
548
  .setRequired(false);
511
549
  return option;
512
550
  })
513
551
  .addStringOption((option) => {
514
552
  option
515
553
  .setName('base-branch')
516
- .setDescription('Branch to create the worktree from (default: origin/HEAD or main)')
554
+ .setDescription(truncateCommandDescription('Branch to create the worktree from (default: origin/HEAD or main)'))
517
555
  .setRequired(false)
518
556
  .setAutocomplete(true);
519
557
  return option;
@@ -522,11 +560,11 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
522
560
  .toJSON(),
523
561
  new SlashCommandBuilder()
524
562
  .setName('merge-worktree')
525
- .setDescription('Squash-merge worktree into the default branch. Optionally pick a target branch.')
563
+ .setDescription(truncateCommandDescription('Squash-merge worktree into default branch. Aborts if main has uncommitted changes.'))
526
564
  .addStringOption((option) => {
527
565
  option
528
566
  .setName('target-branch')
529
- .setDescription('Branch to merge into (default: origin/HEAD or main)')
567
+ .setDescription(truncateCommandDescription('Branch to merge into (default: origin/HEAD or main)'))
530
568
  .setRequired(false)
531
569
  .setAutocomplete(true);
532
570
  return option;
@@ -535,26 +573,36 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
535
573
  .toJSON(),
536
574
  new SlashCommandBuilder()
537
575
  .setName('toggle-worktrees')
538
- .setDescription('Toggle automatic git worktree creation for new sessions in this channel')
576
+ .setDescription(truncateCommandDescription('Toggle automatic git worktree creation for new sessions in this channel'))
539
577
  .setDMPermission(false)
540
578
  .toJSON(),
541
579
  new SlashCommandBuilder()
542
580
  .setName('worktrees')
543
- .setDescription('List all active worktree sessions')
581
+ .setDescription(truncateCommandDescription('List all active worktree sessions'))
582
+ .setDMPermission(false)
583
+ .toJSON(),
584
+ new SlashCommandBuilder()
585
+ .setName('tasks')
586
+ .setDescription(truncateCommandDescription('List scheduled tasks created via send --send-at'))
587
+ .addBooleanOption((option) => {
588
+ return option
589
+ .setName('all')
590
+ .setDescription(truncateCommandDescription('Include completed, cancelled, and failed tasks'));
591
+ })
544
592
  .setDMPermission(false)
545
593
  .toJSON(),
546
594
  new SlashCommandBuilder()
547
595
  .setName('toggle-mention-mode')
548
- .setDescription('Toggle mention-only mode (bot only responds when @mentioned)')
596
+ .setDescription(truncateCommandDescription('Toggle mention-only mode (bot only responds when @mentioned)'))
549
597
  .setDMPermission(false)
550
598
  .toJSON(),
551
599
  new SlashCommandBuilder()
552
600
  .setName('add-project')
553
- .setDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects')
601
+ .setDescription(truncateCommandDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects'))
554
602
  .addStringOption((option) => {
555
603
  option
556
604
  .setName('project')
557
- .setDescription('Recent OpenCode projects. Use `npx kimaki project add` if not listed')
605
+ .setDescription(truncateCommandDescription('Recent OpenCode projects. Use `npx kimaki project add` if not listed'))
558
606
  .setRequired(true)
559
607
  .setAutocomplete(true);
560
608
  return option;
@@ -563,11 +611,11 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
563
611
  .toJSON(),
564
612
  new SlashCommandBuilder()
565
613
  .setName('remove-project')
566
- .setDescription('Remove Discord channels for a project')
614
+ .setDescription(truncateCommandDescription('Remove Discord channels for a project'))
567
615
  .addStringOption((option) => {
568
616
  option
569
617
  .setName('project')
570
- .setDescription('Select a project to remove')
618
+ .setDescription(truncateCommandDescription('Select a project to remove'))
571
619
  .setRequired(true)
572
620
  .setAutocomplete(true);
573
621
  return option;
@@ -576,11 +624,11 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
576
624
  .toJSON(),
577
625
  new SlashCommandBuilder()
578
626
  .setName('create-new-project')
579
- .setDescription('Create a new project folder, initialize git, and start a session')
627
+ .setDescription(truncateCommandDescription('Create a new project folder, initialize git, and start a session'))
580
628
  .addStringOption((option) => {
581
629
  option
582
630
  .setName('name')
583
- .setDescription('Name for the new project folder')
631
+ .setDescription(truncateCommandDescription('Name for the new project folder'))
584
632
  .setRequired(true);
585
633
  return option;
586
634
  })
@@ -588,66 +636,66 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
588
636
  .toJSON(),
589
637
  new SlashCommandBuilder()
590
638
  .setName('abort')
591
- .setDescription('Abort the current OpenCode request in this thread')
639
+ .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
592
640
  .setDMPermission(false)
593
641
  .toJSON(),
594
642
  new SlashCommandBuilder()
595
643
  .setName('compact')
596
- .setDescription('Compact the session context by summarizing conversation history')
644
+ .setDescription(truncateCommandDescription('Compact the session context by summarizing conversation history'))
597
645
  .setDMPermission(false)
598
646
  .toJSON(),
599
647
  new SlashCommandBuilder()
600
648
  .setName('stop')
601
- .setDescription('Abort the current OpenCode request in this thread')
649
+ .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
602
650
  .setDMPermission(false)
603
651
  .toJSON(),
604
652
  new SlashCommandBuilder()
605
653
  .setName('share')
606
- .setDescription('Share the current session as a public URL')
654
+ .setDescription(truncateCommandDescription('Share the current session as a public URL'))
607
655
  .setDMPermission(false)
608
656
  .toJSON(),
609
657
  new SlashCommandBuilder()
610
658
  .setName('diff')
611
- .setDescription('Show git diff as a shareable URL')
659
+ .setDescription(truncateCommandDescription('Show git diff as a shareable URL'))
612
660
  .setDMPermission(false)
613
661
  .toJSON(),
614
662
  new SlashCommandBuilder()
615
663
  .setName('fork')
616
- .setDescription('Fork the session from a past user message')
664
+ .setDescription(truncateCommandDescription('Fork the session from a past user message'))
617
665
  .setDMPermission(false)
618
666
  .toJSON(),
619
667
  new SlashCommandBuilder()
620
668
  .setName('model')
621
- .setDescription('Set the preferred model for this channel or session')
669
+ .setDescription(truncateCommandDescription('Set the preferred model for this channel or session'))
622
670
  .setDMPermission(false)
623
671
  .toJSON(),
624
672
  new SlashCommandBuilder()
625
673
  .setName('model-variant')
626
- .setDescription('Quickly change the thinking level variant for the current model')
674
+ .setDescription(truncateCommandDescription('Quickly change the thinking level variant for the current model'))
627
675
  .setDMPermission(false)
628
676
  .toJSON(),
629
677
  new SlashCommandBuilder()
630
678
  .setName('unset-model-override')
631
- .setDescription('Remove model override and use default instead')
679
+ .setDescription(truncateCommandDescription('Remove model override and use default instead'))
632
680
  .setDMPermission(false)
633
681
  .toJSON(),
634
682
  new SlashCommandBuilder()
635
683
  .setName('login')
636
- .setDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect')
684
+ .setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
637
685
  .setDMPermission(false)
638
686
  .toJSON(),
639
687
  new SlashCommandBuilder()
640
688
  .setName('agent')
641
- .setDescription('Set the preferred agent for this channel or session')
689
+ .setDescription(truncateCommandDescription('Set the preferred agent for this channel or session'))
642
690
  .setDMPermission(false)
643
691
  .toJSON(),
644
692
  new SlashCommandBuilder()
645
693
  .setName('queue')
646
- .setDescription('Queue a message to be sent after the current response finishes')
694
+ .setDescription(truncateCommandDescription('Queue a message to be sent after the current response finishes'))
647
695
  .addStringOption((option) => {
648
696
  option
649
697
  .setName('message')
650
- .setDescription('The message to queue')
698
+ .setDescription(truncateCommandDescription('The message to queue'))
651
699
  .setRequired(true);
652
700
  return option;
653
701
  })
@@ -655,16 +703,16 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
655
703
  .toJSON(),
656
704
  new SlashCommandBuilder()
657
705
  .setName('clear-queue')
658
- .setDescription('Clear all queued messages in this thread')
706
+ .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
659
707
  .setDMPermission(false)
660
708
  .toJSON(),
661
709
  new SlashCommandBuilder()
662
710
  .setName('queue-command')
663
- .setDescription('Queue a user command to run after the current response finishes')
711
+ .setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
664
712
  .addStringOption((option) => {
665
713
  option
666
714
  .setName('command')
667
- .setDescription('The command to run')
715
+ .setDescription(truncateCommandDescription('The command to run'))
668
716
  .setRequired(true)
669
717
  .setAutocomplete(true);
670
718
  return option;
@@ -672,7 +720,7 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
672
720
  .addStringOption((option) => {
673
721
  option
674
722
  .setName('arguments')
675
- .setDescription('Arguments to pass to the command')
723
+ .setDescription(truncateCommandDescription('Arguments to pass to the command'))
676
724
  .setRequired(false);
677
725
  return option;
678
726
  })
@@ -680,31 +728,31 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
680
728
  .toJSON(),
681
729
  new SlashCommandBuilder()
682
730
  .setName('undo')
683
- .setDescription('Undo the last assistant message (revert file changes)')
731
+ .setDescription(truncateCommandDescription('Undo the last assistant message (revert file changes)'))
684
732
  .setDMPermission(false)
685
733
  .toJSON(),
686
734
  new SlashCommandBuilder()
687
735
  .setName('redo')
688
- .setDescription('Redo previously undone changes')
736
+ .setDescription(truncateCommandDescription('Redo previously undone changes'))
689
737
  .setDMPermission(false)
690
738
  .toJSON(),
691
739
  new SlashCommandBuilder()
692
740
  .setName('verbosity')
693
- .setDescription('Set output verbosity for this channel')
741
+ .setDescription(truncateCommandDescription('Set output verbosity for this channel'))
694
742
  .setDMPermission(false)
695
743
  .toJSON(),
696
744
  new SlashCommandBuilder()
697
745
  .setName('restart-opencode-server')
698
- .setDescription('Restart the shared opencode server (fixes state/auth/plugins)')
746
+ .setDescription(truncateCommandDescription('Restart the shared opencode server (fixes state/auth/plugins)'))
699
747
  .setDMPermission(false)
700
748
  .toJSON(),
701
749
  new SlashCommandBuilder()
702
750
  .setName('run-shell-command')
703
- .setDescription('Run a shell command in the project directory. Tip: prefix messages with ! as shortcut')
751
+ .setDescription(truncateCommandDescription('Run a shell command in the project directory. Tip: prefix messages with ! as shortcut'))
704
752
  .addStringOption((option) => {
705
753
  option
706
754
  .setName('command')
707
- .setDescription('Command to run')
755
+ .setDescription(truncateCommandDescription('Command to run'))
708
756
  .setRequired(true);
709
757
  return option;
710
758
  })
@@ -712,37 +760,37 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
712
760
  .toJSON(),
713
761
  new SlashCommandBuilder()
714
762
  .setName('context-usage')
715
- .setDescription('Show token usage and context window percentage for this session')
763
+ .setDescription(truncateCommandDescription('Show token usage and context window percentage for this session'))
716
764
  .setDMPermission(false)
717
765
  .toJSON(),
718
766
  new SlashCommandBuilder()
719
767
  .setName('session-id')
720
- .setDescription('Show current session ID and opencode attach command for this thread')
768
+ .setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
721
769
  .setDMPermission(false)
722
770
  .toJSON(),
723
771
  new SlashCommandBuilder()
724
772
  .setName('upgrade-and-restart')
725
- .setDescription('Upgrade kimaki to the latest version and restart the bot')
773
+ .setDescription(truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'))
726
774
  .setDMPermission(false)
727
775
  .toJSON(),
728
776
  new SlashCommandBuilder()
729
777
  .setName('transcription-key')
730
- .setDescription('Set API key for voice message transcription (OpenAI or Gemini)')
778
+ .setDescription(truncateCommandDescription('Set API key for voice message transcription (OpenAI or Gemini)'))
731
779
  .setDMPermission(false)
732
780
  .toJSON(),
733
781
  new SlashCommandBuilder()
734
782
  .setName('mcp')
735
- .setDescription('List and manage MCP servers for this project')
783
+ .setDescription(truncateCommandDescription('List and manage MCP servers for this project'))
736
784
  .setDMPermission(false)
737
785
  .toJSON(),
738
786
  new SlashCommandBuilder()
739
787
  .setName('screenshare')
740
- .setDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)')
788
+ .setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)'))
741
789
  .setDMPermission(false)
742
790
  .toJSON(),
743
791
  new SlashCommandBuilder()
744
792
  .setName('screenshare-stop')
745
- .setDescription('Stop screen sharing')
793
+ .setDescription(truncateCommandDescription('Stop screen sharing'))
746
794
  .setDMPermission(false)
747
795
  .toJSON(),
748
796
  ];
@@ -780,11 +828,11 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
780
828
  });
781
829
  commands.push(new SlashCommandBuilder()
782
830
  .setName(commandName)
783
- .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
831
+ .setDescription(truncateCommandDescription(description))
784
832
  .addStringOption((option) => {
785
833
  option
786
834
  .setName('arguments')
787
- .setDescription('Arguments to pass to the command')
835
+ .setDescription(truncateCommandDescription('Arguments to pass to the command'))
788
836
  .setRequired(false);
789
837
  return option;
790
838
  })
@@ -813,7 +861,7 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
813
861
  });
814
862
  commands.push(new SlashCommandBuilder()
815
863
  .setName(commandName)
816
- .setDescription(description)
864
+ .setDescription(truncateCommandDescription(description))
817
865
  .setDMPermission(false)
818
866
  .toJSON());
819
867
  }
@@ -1005,7 +1053,7 @@ async function ensureDefaultChannelsWithWelcome({ guilds, discordClient, appId,
1005
1053
  }
1006
1054
  }
1007
1055
  catch (error) {
1008
- cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
1056
+ cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.stack : String(error)}`);
1009
1057
  }
1010
1058
  }
1011
1059
  return created;
@@ -1035,14 +1083,14 @@ async function backgroundInit({ currentDir, token, appId, guildIds, }) {
1035
1083
  .command.list({ directory: currentDir })
1036
1084
  .then((r) => r.data || [])
1037
1085
  .catch((error) => {
1038
- cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.message : String(error));
1086
+ cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.stack : String(error));
1039
1087
  return [];
1040
1088
  }),
1041
1089
  getClient()
1042
1090
  .app.agents({ directory: currentDir })
1043
1091
  .then((r) => r.data || [])
1044
1092
  .catch((error) => {
1045
- cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.message : String(error));
1093
+ cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.stack : String(error));
1046
1094
  return [];
1047
1095
  }),
1048
1096
  ]);
@@ -1050,7 +1098,7 @@ async function backgroundInit({ currentDir, token, appId, guildIds, }) {
1050
1098
  cliLogger.log('Slash commands registered!');
1051
1099
  }
1052
1100
  catch (error) {
1053
- cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
1101
+ cliLogger.error('Background init failed:', error instanceof Error ? error.stack : String(error));
1054
1102
  void notifyError(error, 'Background init failed');
1055
1103
  }
1056
1104
  }
@@ -1166,6 +1214,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
1166
1214
  clientId,
1167
1215
  clientSecret,
1168
1216
  gatewayCallbackUrl,
1217
+ reachableUrl: getInternetReachableBaseUrl() || undefined,
1169
1218
  });
1170
1219
  if (oauthUrlResult instanceof Error) {
1171
1220
  throw oauthUrlResult;
@@ -1319,32 +1368,35 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1319
1368
  startCaffeinate();
1320
1369
  const forceRestartOnboarding = Boolean(restartOnboarding);
1321
1370
  const forceGateway = Boolean(gateway);
1322
- // Step 0: Ensure required CLI tools are installed (OpenCode + Bun)
1323
- await ensureCommandAvailable({
1324
- name: 'opencode',
1325
- envPathKey: 'OPENCODE_PATH',
1326
- installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1327
- installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1328
- possiblePathsUnix: [
1329
- '~/.local/bin/opencode',
1330
- '~/.opencode/bin/opencode',
1331
- '/usr/local/bin/opencode',
1332
- '/opt/opencode/bin/opencode',
1333
- ],
1334
- possiblePathsWindows: [
1335
- '~\\.local\\bin\\opencode.exe',
1336
- '~\\AppData\\Local\\opencode\\opencode.exe',
1337
- '~\\.opencode\\bin\\opencode.exe',
1338
- ],
1339
- });
1340
- await ensureCommandAvailable({
1341
- name: 'bun',
1342
- envPathKey: 'BUN_PATH',
1343
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
1344
- installWindows: 'irm bun.sh/install.ps1 | iex',
1345
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1346
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1347
- });
1371
+ // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
1372
+ // Run checks in parallel since they're independent `which` calls.
1373
+ await Promise.all([
1374
+ ensureCommandAvailable({
1375
+ name: 'opencode',
1376
+ envPathKey: 'OPENCODE_PATH',
1377
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1378
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1379
+ possiblePathsUnix: [
1380
+ '~/.local/bin/opencode',
1381
+ '~/.opencode/bin/opencode',
1382
+ '/usr/local/bin/opencode',
1383
+ '/opt/opencode/bin/opencode',
1384
+ ],
1385
+ possiblePathsWindows: [
1386
+ '~\\.local\\bin\\opencode.exe',
1387
+ '~\\AppData\\Local\\opencode\\opencode.exe',
1388
+ '~\\.opencode\\bin\\opencode.exe',
1389
+ ],
1390
+ }),
1391
+ ensureCommandAvailable({
1392
+ name: 'bun',
1393
+ envPathKey: 'BUN_PATH',
1394
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
1395
+ installWindows: 'irm bun.sh/install.ps1 | iex',
1396
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1397
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1398
+ }),
1399
+ ]);
1348
1400
  backgroundUpgradeKimaki();
1349
1401
  // Start in-process Hrana server before database init. Required for the bot
1350
1402
  // process because it serves as both the DB server and the single-instance
@@ -1352,6 +1404,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1352
1404
  // don't work. CLI subcommands skip the server and use file: directly.
1353
1405
  const hranaResult = await startHranaServer({
1354
1406
  dbPath: path.join(getDataDir(), 'discord-sessions.db'),
1407
+ bindAll: getInternetReachableBaseUrl() !== null,
1355
1408
  });
1356
1409
  if (hranaResult instanceof Error) {
1357
1410
  cliLogger.error('Failed to start hrana server:', hranaResult.message);
@@ -1364,6 +1417,13 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1364
1417
  forceGateway,
1365
1418
  gatewayCallbackUrl,
1366
1419
  });
1420
+ const gatewayToken = await ensureServiceAuthToken({
1421
+ appId,
1422
+ preferredGatewayToken: isGatewayMode ? token : undefined,
1423
+ });
1424
+ // Always set service auth token so local and internet control-plane paths
1425
+ // share one auth model (/kimaki/wake and future service endpoints).
1426
+ store.setState({ gatewayToken });
1367
1427
  // In gateway mode, ensure REST calls route through the gateway proxy.
1368
1428
  // getBotTokenWithMode() sets this for saved-credential paths, but the fresh
1369
1429
  // onboarding path returns directly without going through getBotTokenWithMode(),
@@ -1373,6 +1433,30 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1373
1433
  if (isGatewayMode) {
1374
1434
  store.setState({ discordBaseUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL });
1375
1435
  }
1436
+ // When KIMAKI_INTERNET_REACHABLE_URL is set, the hrana server exposes
1437
+ // a /kimaki/wake endpoint for the gateway-proxy to wake this instance and
1438
+ // wait until discord.js is connected. Keep Discord traffic on the normal
1439
+ // configured base URL (gateway-proxy in gateway mode).
1440
+ if (getInternetReachableBaseUrl()) {
1441
+ cliLogger.log('Internet-reachable mode: enabling /kimaki/wake endpoint on hrana server');
1442
+ }
1443
+ // Start OpenCode server as early as possible — non-blocking.
1444
+ // All dependencies are met (dataDir, lockPort, gatewayToken, hranaUrl set).
1445
+ // Runs in parallel with last_used_at update, skipChannelSetup check, and
1446
+ // Discord Gateway login so cold start is not blocked by OpenCode spawn.
1447
+ const currentDir = process.cwd();
1448
+ cliLogger.log('Starting OpenCode server...');
1449
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
1450
+ if (result instanceof Error) {
1451
+ throw new Error(result.message);
1452
+ }
1453
+ cliLogger.log('OpenCode server ready!');
1454
+ return result;
1455
+ });
1456
+ // Prevent unhandled rejection if OpenCode fails before backgroundInit
1457
+ // or the channel setup path awaits it. Errors are handled by the
1458
+ // respective consumers (backgroundInit catches, channel setup re-throws).
1459
+ opencodePromise.catch(() => { });
1376
1460
  // Mark this bot as the most recently used so subcommands in separate
1377
1461
  // processes (send, upload-to-discord, project list) pick the correct bot.
1378
1462
  // getBotTokenWithMode() orders by last_used_at DESC as cross-process
@@ -1408,16 +1492,6 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1408
1492
  }
1409
1493
  return true;
1410
1494
  })();
1411
- // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
1412
- // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
1413
- const currentDir = process.cwd();
1414
- cliLogger.log('Starting OpenCode server...');
1415
- const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
1416
- if (result instanceof Error) {
1417
- throw new Error(result.message);
1418
- }
1419
- return result;
1420
- });
1421
1495
  cliLogger.log(`Connecting to ${getDiscordRestApiUrl()}...`);
1422
1496
  const discordClient = await createDiscordClient();
1423
1497
  const guilds = [];
@@ -1467,7 +1541,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1467
1541
  }
1468
1542
  catch (error) {
1469
1543
  cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway);
1470
- cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
1544
+ cliLogger.error('Error: ' + (error instanceof Error ? error.stack : String(error)));
1471
1545
  process.exit(EXIT_NO_RESTART);
1472
1546
  }
1473
1547
  await setBotToken(appId, token);
@@ -1518,7 +1592,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1518
1592
  cliLogger.log(`Background channel sync completed for ${backgroundChannels.length} guild(s)`);
1519
1593
  }
1520
1594
  catch (error) {
1521
- cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.message : String(error));
1595
+ cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.stack : String(error));
1522
1596
  }
1523
1597
  // Create default kimaki channel + welcome message in each guild.
1524
1598
  // Runs after channel sync so existing channels are detected correctly.
@@ -1532,7 +1606,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1532
1606
  });
1533
1607
  }
1534
1608
  catch (error) {
1535
- cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.message : String(error));
1609
+ cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.stack : String(error));
1536
1610
  }
1537
1611
  })();
1538
1612
  // Background: OpenCode init + slash command registration (non-blocking)
@@ -1563,7 +1637,6 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1563
1637
  // Wait for OpenCode, fetch projects, show prompts, create channels if needed
1564
1638
  cliLogger.log('Waiting for OpenCode server...');
1565
1639
  const getClient = await opencodePromise;
1566
- cliLogger.log('OpenCode server ready!');
1567
1640
  cliLogger.log('Fetching OpenCode data...');
1568
1641
  // Fetch projects, commands, and agents in parallel
1569
1642
  const [projects, allUserCommands, allAgents] = await Promise.all([
@@ -1572,7 +1645,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1572
1645
  .then((r) => r.data || [])
1573
1646
  .catch((error) => {
1574
1647
  cliLogger.log('Failed to fetch projects');
1575
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1648
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
1576
1649
  discordClient.destroy();
1577
1650
  process.exit(EXIT_NO_RESTART);
1578
1651
  }),
@@ -1580,14 +1653,14 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1580
1653
  .command.list({ directory: currentDir })
1581
1654
  .then((r) => r.data || [])
1582
1655
  .catch((error) => {
1583
- cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.message : String(error));
1656
+ cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.stack : String(error));
1584
1657
  return [];
1585
1658
  }),
1586
1659
  getClient()
1587
1660
  .app.agents({ directory: currentDir })
1588
1661
  .then((r) => r.data || [])
1589
1662
  .catch((error) => {
1590
- cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.message : String(error));
1663
+ cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.stack : String(error));
1591
1664
  return [];
1592
1665
  }),
1593
1666
  ]);
@@ -1703,7 +1776,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1703
1776
  cliLogger.log('Slash commands registered!');
1704
1777
  })
1705
1778
  .catch((error) => {
1706
- cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
1779
+ cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.stack : String(error));
1707
1780
  });
1708
1781
  // Start bot after channel setup is complete so it doesn't handle
1709
1782
  // messages/interactions while the user is still going through prompts.
@@ -1838,7 +1911,7 @@ cli
1838
1911
  });
1839
1912
  }
1840
1913
  catch (error) {
1841
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1914
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
1842
1915
  process.exit(EXIT_NO_RESTART);
1843
1916
  }
1844
1917
  });
@@ -1874,7 +1947,7 @@ cli
1874
1947
  });
1875
1948
  }
1876
1949
  catch (error) {
1877
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1950
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
1878
1951
  process.exit(EXIT_NO_RESTART);
1879
1952
  }
1880
1953
  });
@@ -1975,7 +2048,7 @@ cli
1975
2048
  process.exit(0);
1976
2049
  }
1977
2050
  catch (error) {
1978
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2051
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
1979
2052
  process.exit(EXIT_NO_RESTART);
1980
2053
  }
1981
2054
  });
@@ -2015,7 +2088,7 @@ cli
2015
2088
  process.exit(0);
2016
2089
  }
2017
2090
  catch (error) {
2018
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2091
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2019
2092
  process.exit(EXIT_NO_RESTART);
2020
2093
  }
2021
2094
  });
@@ -2062,7 +2135,7 @@ cli
2062
2135
  process.exit(0);
2063
2136
  }
2064
2137
  catch (error) {
2065
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2138
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2066
2139
  process.exit(EXIT_NO_RESTART);
2067
2140
  }
2068
2141
  });
@@ -2079,6 +2152,8 @@ cli
2079
2152
  .option('-u, --user <username>', 'Discord username to add to thread')
2080
2153
  .option('--agent <agent>', 'Agent to use for the session')
2081
2154
  .option('--model <model>', 'Model to use (format: provider/model)')
2155
+ .option('--permission <rule>', z.array(z.string()).describe('Session permission rule (repeatable). Format: "tool:action" or "tool:pattern:action". ' +
2156
+ 'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"'))
2082
2157
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
2083
2158
  .option('--thread <threadId>', 'Post prompt to an existing thread')
2084
2159
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
@@ -2119,10 +2194,12 @@ cli
2119
2194
  if (!sendAt) {
2120
2195
  return null;
2121
2196
  }
2197
+ // Cron expressions use UTC so the schedule is consistent regardless of
2198
+ // which machine runs the bot. The system message tells the model to use UTC.
2122
2199
  return parseSendAtValue({
2123
2200
  value: sendAt,
2124
2201
  now: new Date(),
2125
- timezone: getLocalTimeZone(),
2202
+ timezone: 'UTC',
2126
2203
  });
2127
2204
  })();
2128
2205
  if (parsedSchedule instanceof Error) {
@@ -2238,7 +2315,7 @@ cli
2238
2315
  }
2239
2316
  }
2240
2317
  catch (error) {
2241
- cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
2318
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
2242
2319
  }
2243
2320
  }
2244
2321
  // Fall back to first guild the bot is in
@@ -2306,6 +2383,7 @@ cli
2306
2383
  model: options.model || null,
2307
2384
  username: null,
2308
2385
  userId: null,
2386
+ permissions: options.permission?.length ? options.permission : null,
2309
2387
  };
2310
2388
  const taskId = await createScheduledTask({
2311
2389
  scheduleKind: parsedSchedule.scheduleKind,
@@ -2327,6 +2405,7 @@ cli
2327
2405
  }
2328
2406
  const threadPromptMarker = {
2329
2407
  cliThreadPrompt: true,
2408
+ ...(options.permission?.length ? { permissions: options.permission } : {}),
2330
2409
  };
2331
2410
  const promptEmbed = [
2332
2411
  {
@@ -2417,6 +2496,7 @@ cli
2417
2496
  model: options.model || null,
2418
2497
  username: resolvedUser?.username || null,
2419
2498
  userId: resolvedUser?.id || null,
2499
+ permissions: options.permission?.length ? options.permission : null,
2420
2500
  };
2421
2501
  const taskId = await createScheduledTask({
2422
2502
  scheduleKind: parsedSchedule.scheduleKind,
@@ -2447,6 +2527,7 @@ cli
2447
2527
  }),
2448
2528
  ...(options.agent && { agent: options.agent }),
2449
2529
  ...(options.model && { model: options.model }),
2530
+ ...(options.permission?.length && { permissions: options.permission }),
2450
2531
  };
2451
2532
  const autoStartEmbed = embedMarker
2452
2533
  ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
@@ -2490,7 +2571,7 @@ cli
2490
2571
  process.exit(0);
2491
2572
  }
2492
2573
  catch (error) {
2493
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2574
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2494
2575
  process.exit(EXIT_NO_RESTART);
2495
2576
  }
2496
2577
  });
@@ -2526,7 +2607,7 @@ cli
2526
2607
  process.exit(0);
2527
2608
  }
2528
2609
  catch (error) {
2529
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2610
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2530
2611
  process.exit(EXIT_NO_RESTART);
2531
2612
  }
2532
2613
  });
@@ -2549,7 +2630,85 @@ cli
2549
2630
  process.exit(0);
2550
2631
  }
2551
2632
  catch (error) {
2552
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
2633
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2634
+ process.exit(EXIT_NO_RESTART);
2635
+ }
2636
+ });
2637
+ cli
2638
+ .command('task edit <id>', 'Edit prompt or schedule of a planned task')
2639
+ .option('--prompt <prompt>', 'New prompt text')
2640
+ .option('--send-at <sendAt>', 'New schedule (UTC ISO date or cron expression)')
2641
+ .action(async (id, options) => {
2642
+ try {
2643
+ const trimmedPrompt = options.prompt === undefined ? undefined : options.prompt.trim();
2644
+ if (!trimmedPrompt && !options.sendAt) {
2645
+ cliLogger.error('Provide at least --prompt or --send-at');
2646
+ process.exit(EXIT_NO_RESTART);
2647
+ }
2648
+ if (trimmedPrompt !== undefined && trimmedPrompt.length === 0) {
2649
+ cliLogger.error('--prompt cannot be empty');
2650
+ process.exit(EXIT_NO_RESTART);
2651
+ }
2652
+ if (trimmedPrompt !== undefined && trimmedPrompt.length > 1900) {
2653
+ cliLogger.error('--prompt currently supports up to 1900 characters');
2654
+ process.exit(EXIT_NO_RESTART);
2655
+ }
2656
+ const taskId = Number.parseInt(id, 10);
2657
+ if (Number.isNaN(taskId) || taskId < 1) {
2658
+ cliLogger.error(`Invalid task ID: ${id}`);
2659
+ process.exit(EXIT_NO_RESTART);
2660
+ }
2661
+ await initDatabase();
2662
+ const task = await getScheduledTask(taskId);
2663
+ if (!task) {
2664
+ cliLogger.error(`Task ${taskId} not found`);
2665
+ process.exit(EXIT_NO_RESTART);
2666
+ }
2667
+ if (task.status !== 'planned') {
2668
+ cliLogger.error(`Task ${taskId} is ${task.status}, only planned tasks can be edited`);
2669
+ process.exit(EXIT_NO_RESTART);
2670
+ }
2671
+ const existingPayload = parseScheduledTaskPayload(task.payload_json);
2672
+ if (existingPayload instanceof Error) {
2673
+ cliLogger.error(`Failed to parse task payload: ${existingPayload.message}`);
2674
+ process.exit(EXIT_NO_RESTART);
2675
+ }
2676
+ const newPrompt = trimmedPrompt ?? existingPayload.prompt;
2677
+ const updatedPayload = {
2678
+ ...existingPayload,
2679
+ prompt: newPrompt,
2680
+ };
2681
+ const updateData = {
2682
+ taskId,
2683
+ payloadJson: serializeScheduledTaskPayload(updatedPayload),
2684
+ promptPreview: getPromptPreview(newPrompt),
2685
+ };
2686
+ if (options.sendAt) {
2687
+ const parsed = parseSendAtValue({
2688
+ value: options.sendAt,
2689
+ now: new Date(),
2690
+ timezone: 'UTC',
2691
+ });
2692
+ if (parsed instanceof Error) {
2693
+ cliLogger.error(`Invalid --send-at: ${parsed.message}`);
2694
+ process.exit(EXIT_NO_RESTART);
2695
+ }
2696
+ updateData.scheduleKind = parsed.scheduleKind;
2697
+ updateData.runAt = parsed.runAt;
2698
+ updateData.cronExpr = parsed.cronExpr;
2699
+ updateData.timezone = parsed.timezone;
2700
+ updateData.nextRunAt = parsed.nextRunAt;
2701
+ }
2702
+ const updated = await updateScheduledTask(updateData);
2703
+ if (!updated) {
2704
+ cliLogger.error(`Task ${taskId} could not be updated (status may have changed)`);
2705
+ process.exit(EXIT_NO_RESTART);
2706
+ }
2707
+ cliLogger.log(`Updated task ${taskId}`);
2708
+ process.exit(0);
2709
+ }
2710
+ catch (error) {
2711
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2553
2712
  process.exit(EXIT_NO_RESTART);
2554
2713
  }
2555
2714
  });
@@ -2613,7 +2772,7 @@ cli
2613
2772
  }
2614
2773
  }
2615
2774
  catch (error) {
2616
- cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
2775
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
2617
2776
  let firstGuild = client.guilds.cache.first();
2618
2777
  if (!firstGuild) {
2619
2778
  // Cache might be empty, try fetching guilds from API
@@ -2668,12 +2827,12 @@ cli
2668
2827
  }
2669
2828
  }
2670
2829
  catch (error) {
2671
- cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.message : String(error));
2830
+ cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.stack : String(error));
2672
2831
  }
2673
2832
  }
2674
2833
  }
2675
2834
  catch (error) {
2676
- cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.message : String(error));
2835
+ cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.stack : String(error));
2677
2836
  }
2678
2837
  cliLogger.log(`Creating channels in ${guild.name}...`);
2679
2838
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
@@ -2914,7 +3073,7 @@ cli
2914
3073
  process.exit(0);
2915
3074
  }
2916
3075
  catch (error) {
2917
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
3076
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2918
3077
  process.exit(EXIT_NO_RESTART);
2919
3078
  }
2920
3079
  });
@@ -3040,7 +3199,7 @@ cli
3040
3199
  process.exit(0);
3041
3200
  }
3042
3201
  catch (error) {
3043
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
3202
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3044
3203
  process.exit(EXIT_NO_RESTART);
3045
3204
  }
3046
3205
  });
@@ -3103,7 +3262,7 @@ cli
3103
3262
  process.exit(EXIT_NO_RESTART);
3104
3263
  }
3105
3264
  catch (error) {
3106
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
3265
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3107
3266
  process.exit(EXIT_NO_RESTART);
3108
3267
  }
3109
3268
  });
@@ -3254,7 +3413,7 @@ cli
3254
3413
  process.exit(0);
3255
3414
  }
3256
3415
  catch (error) {
3257
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
3416
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3258
3417
  process.exit(EXIT_NO_RESTART);
3259
3418
  }
3260
3419
  });
@@ -3392,10 +3551,38 @@ cli
3392
3551
  process.exit(0);
3393
3552
  }
3394
3553
  catch (error) {
3395
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
3554
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3396
3555
  process.exit(EXIT_NO_RESTART);
3397
3556
  }
3398
3557
  });
3558
+ cli
3559
+ .command('session discord-url <sessionId>', 'Print the Discord thread URL for a session')
3560
+ .option('--json', 'Output as JSON')
3561
+ .action(async (sessionId, options) => {
3562
+ await initDatabase();
3563
+ const threadId = await getThreadIdBySessionId(sessionId);
3564
+ if (!threadId) {
3565
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`);
3566
+ process.exit(EXIT_NO_RESTART);
3567
+ }
3568
+ const { token: botToken } = await resolveBotCredentials();
3569
+ const rest = createDiscordRest(botToken);
3570
+ const threadData = (await rest.get(Routes.channel(threadId)));
3571
+ const url = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
3572
+ if (options.json) {
3573
+ console.log(JSON.stringify({
3574
+ url,
3575
+ threadId: threadData.id,
3576
+ guildId: threadData.guild_id,
3577
+ sessionId,
3578
+ threadName: threadData.name,
3579
+ }));
3580
+ }
3581
+ else {
3582
+ console.log(url);
3583
+ }
3584
+ process.exit(0);
3585
+ });
3399
3586
  cli
3400
3587
  .command('upgrade', 'Upgrade kimaki to the latest version and restart the running bot')
3401
3588
  .option('--skip-restart', 'Only upgrade, do not restart the running bot')
@@ -3425,7 +3612,7 @@ cli
3425
3612
  process.exit(0);
3426
3613
  }
3427
3614
  catch (error) {
3428
- cliLogger.error('Upgrade failed:', error instanceof Error ? error.message : String(error));
3615
+ cliLogger.error('Upgrade failed:', error instanceof Error ? error.stack : String(error));
3429
3616
  process.exit(EXIT_NO_RESTART);
3430
3617
  }
3431
3618
  });
@@ -3495,7 +3682,7 @@ cli
3495
3682
  process.exit(0);
3496
3683
  }
3497
3684
  catch (error) {
3498
- cliLogger.error('Merge failed:', error instanceof Error ? error.message : String(error));
3685
+ cliLogger.error('Merge failed:', error instanceof Error ? error.stack : String(error));
3499
3686
  process.exit(EXIT_NO_RESTART);
3500
3687
  }
3501
3688
  });