kimaki 0.4.81 → 0.4.82

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/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { sendWelcomeMessage } from './onboarding-welcome.js';
16
16
  import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
17
17
  import { selectResolvedCommand } from './opencode-command.js';
18
18
  import yaml from 'js-yaml';
19
- import { Events, ChannelType, ActivityType, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
19
+ import { Events, ChannelType, ActivityType, Routes, AttachmentBuilder, } from 'discord.js';
20
20
  import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
21
21
  import crypto from 'node:crypto';
22
22
  import path from 'node:path';
@@ -27,7 +27,6 @@ import { initSentry, notifyError } from './sentry.js';
27
27
  import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
28
28
  import { spawn, execSync } from 'node:child_process';
29
29
  import { setDataDir, getDataDir, getProjectsDir, } from './config.js';
30
- import { sanitizeAgentName, buildQuickAgentCommandDescription, } from './commands/agent.js';
31
30
  import { execAsync } from './worktrees.js';
32
31
  import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
33
32
  import { startHranaServer } from './hrana-server.js';
@@ -437,509 +436,8 @@ function startCaffeinate() {
437
436
  }
438
437
  const cli = goke('kimaki');
439
438
  process.title = 'kimaki';
440
- // Commands to skip when registering user commands (reserved names)
441
- const SKIP_USER_COMMANDS = ['init'];
442
- function getDiscordCommandSuffix(command) {
443
- if (command.source === 'skill') {
444
- return '-skill';
445
- }
446
- if (command.source === 'mcp') {
447
- return '-mcp-prompt';
448
- }
449
- return '-cmd';
450
- }
451
439
  import { store } from './store.js';
452
- function isDiscordCommandSummary(value) {
453
- if (typeof value !== 'object' || value === null) {
454
- return false;
455
- }
456
- const id = Reflect.get(value, 'id');
457
- const name = Reflect.get(value, 'name');
458
- return typeof id === 'string' && typeof name === 'string';
459
- }
460
- async function deleteLegacyGlobalCommands({ rest, appId, commandNames, }) {
461
- try {
462
- const response = await rest.get(Routes.applicationCommands(appId));
463
- if (!Array.isArray(response)) {
464
- cliLogger.warn('COMMANDS: Unexpected global command payload while cleaning legacy global commands');
465
- return;
466
- }
467
- const legacyGlobalCommands = response
468
- .filter(isDiscordCommandSummary)
469
- .filter((command) => {
470
- return commandNames.has(command.name);
471
- });
472
- if (legacyGlobalCommands.length === 0) {
473
- return;
474
- }
475
- const deletionResults = await Promise.allSettled(legacyGlobalCommands.map(async (command) => {
476
- await rest.delete(Routes.applicationCommand(appId, command.id));
477
- return command;
478
- }));
479
- const failedDeletions = deletionResults.filter((result) => {
480
- return result.status === 'rejected';
481
- });
482
- if (failedDeletions.length > 0) {
483
- cliLogger.warn(`COMMANDS: Failed to delete ${failedDeletions.length} legacy global command(s)`);
484
- }
485
- const deletedCount = deletionResults.length - failedDeletions.length;
486
- if (deletedCount > 0) {
487
- cliLogger.info(`COMMANDS: Deleted ${deletedCount} legacy global command(s) to avoid guild/global duplicates`);
488
- }
489
- }
490
- catch (error) {
491
- cliLogger.warn(`COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.stack : String(error)}`);
492
- }
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
- }
499
- async function registerCommands({ token, appId, guildIds, userCommands = [], agents = [], }) {
500
- const commands = [
501
- new SlashCommandBuilder()
502
- .setName('resume')
503
- .setDescription(truncateCommandDescription('Resume an existing OpenCode session'))
504
- .addStringOption((option) => {
505
- option
506
- .setName('session')
507
- .setDescription(truncateCommandDescription('The session to resume'))
508
- .setRequired(true)
509
- .setAutocomplete(true);
510
- return option;
511
- })
512
- .setDMPermission(false)
513
- .toJSON(),
514
- new SlashCommandBuilder()
515
- .setName('new-session')
516
- .setDescription(truncateCommandDescription('Start a new OpenCode session'))
517
- .addStringOption((option) => {
518
- option
519
- .setName('prompt')
520
- .setDescription(truncateCommandDescription('Prompt content for the session'))
521
- .setRequired(true);
522
- return option;
523
- })
524
- .addStringOption((option) => {
525
- option
526
- .setName('files')
527
- .setDescription(truncateCommandDescription('Files to mention (comma or space separated; autocomplete)'))
528
- .setAutocomplete(true)
529
- .setMaxLength(6000);
530
- return option;
531
- })
532
- .addStringOption((option) => {
533
- option
534
- .setName('agent')
535
- .setDescription(truncateCommandDescription('Agent to use for this session'))
536
- .setAutocomplete(true);
537
- return option;
538
- })
539
- .setDMPermission(false)
540
- .toJSON(),
541
- new SlashCommandBuilder()
542
- .setName('new-worktree')
543
- .setDescription(truncateCommandDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.'))
544
- .addStringOption((option) => {
545
- option
546
- .setName('name')
547
- .setDescription(truncateCommandDescription('Name for worktree (optional in threads - uses thread name)'))
548
- .setRequired(false);
549
- return option;
550
- })
551
- .addStringOption((option) => {
552
- option
553
- .setName('base-branch')
554
- .setDescription(truncateCommandDescription('Branch to create the worktree from (default: origin/HEAD or main)'))
555
- .setRequired(false)
556
- .setAutocomplete(true);
557
- return option;
558
- })
559
- .setDMPermission(false)
560
- .toJSON(),
561
- new SlashCommandBuilder()
562
- .setName('merge-worktree')
563
- .setDescription(truncateCommandDescription('Squash-merge worktree into default branch. Aborts if main has uncommitted changes.'))
564
- .addStringOption((option) => {
565
- option
566
- .setName('target-branch')
567
- .setDescription(truncateCommandDescription('Branch to merge into (default: origin/HEAD or main)'))
568
- .setRequired(false)
569
- .setAutocomplete(true);
570
- return option;
571
- })
572
- .setDMPermission(false)
573
- .toJSON(),
574
- new SlashCommandBuilder()
575
- .setName('toggle-worktrees')
576
- .setDescription(truncateCommandDescription('Toggle automatic git worktree creation for new sessions in this channel'))
577
- .setDMPermission(false)
578
- .toJSON(),
579
- new SlashCommandBuilder()
580
- .setName('worktrees')
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
- })
592
- .setDMPermission(false)
593
- .toJSON(),
594
- new SlashCommandBuilder()
595
- .setName('toggle-mention-mode')
596
- .setDescription(truncateCommandDescription('Toggle mention-only mode (bot only responds when @mentioned)'))
597
- .setDMPermission(false)
598
- .toJSON(),
599
- new SlashCommandBuilder()
600
- .setName('add-project')
601
- .setDescription(truncateCommandDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects'))
602
- .addStringOption((option) => {
603
- option
604
- .setName('project')
605
- .setDescription(truncateCommandDescription('Recent OpenCode projects. Use `npx kimaki project add` if not listed'))
606
- .setRequired(true)
607
- .setAutocomplete(true);
608
- return option;
609
- })
610
- .setDMPermission(false)
611
- .toJSON(),
612
- new SlashCommandBuilder()
613
- .setName('remove-project')
614
- .setDescription(truncateCommandDescription('Remove Discord channels for a project'))
615
- .addStringOption((option) => {
616
- option
617
- .setName('project')
618
- .setDescription(truncateCommandDescription('Select a project to remove'))
619
- .setRequired(true)
620
- .setAutocomplete(true);
621
- return option;
622
- })
623
- .setDMPermission(false)
624
- .toJSON(),
625
- new SlashCommandBuilder()
626
- .setName('create-new-project')
627
- .setDescription(truncateCommandDescription('Create a new project folder, initialize git, and start a session'))
628
- .addStringOption((option) => {
629
- option
630
- .setName('name')
631
- .setDescription(truncateCommandDescription('Name for the new project folder'))
632
- .setRequired(true);
633
- return option;
634
- })
635
- .setDMPermission(false)
636
- .toJSON(),
637
- new SlashCommandBuilder()
638
- .setName('abort')
639
- .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
640
- .setDMPermission(false)
641
- .toJSON(),
642
- new SlashCommandBuilder()
643
- .setName('compact')
644
- .setDescription(truncateCommandDescription('Compact the session context by summarizing conversation history'))
645
- .setDMPermission(false)
646
- .toJSON(),
647
- new SlashCommandBuilder()
648
- .setName('stop')
649
- .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
650
- .setDMPermission(false)
651
- .toJSON(),
652
- new SlashCommandBuilder()
653
- .setName('share')
654
- .setDescription(truncateCommandDescription('Share the current session as a public URL'))
655
- .setDMPermission(false)
656
- .toJSON(),
657
- new SlashCommandBuilder()
658
- .setName('diff')
659
- .setDescription(truncateCommandDescription('Show git diff as a shareable URL'))
660
- .setDMPermission(false)
661
- .toJSON(),
662
- new SlashCommandBuilder()
663
- .setName('fork')
664
- .setDescription(truncateCommandDescription('Fork the session from a past user message'))
665
- .setDMPermission(false)
666
- .toJSON(),
667
- new SlashCommandBuilder()
668
- .setName('model')
669
- .setDescription(truncateCommandDescription('Set the preferred model for this channel or session'))
670
- .setDMPermission(false)
671
- .toJSON(),
672
- new SlashCommandBuilder()
673
- .setName('model-variant')
674
- .setDescription(truncateCommandDescription('Quickly change the thinking level variant for the current model'))
675
- .setDMPermission(false)
676
- .toJSON(),
677
- new SlashCommandBuilder()
678
- .setName('unset-model-override')
679
- .setDescription(truncateCommandDescription('Remove model override and use default instead'))
680
- .setDMPermission(false)
681
- .toJSON(),
682
- new SlashCommandBuilder()
683
- .setName('login')
684
- .setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
685
- .setDMPermission(false)
686
- .toJSON(),
687
- new SlashCommandBuilder()
688
- .setName('agent')
689
- .setDescription(truncateCommandDescription('Set the preferred agent for this channel or session'))
690
- .setDMPermission(false)
691
- .toJSON(),
692
- new SlashCommandBuilder()
693
- .setName('queue')
694
- .setDescription(truncateCommandDescription('Queue a message to be sent after the current response finishes'))
695
- .addStringOption((option) => {
696
- option
697
- .setName('message')
698
- .setDescription(truncateCommandDescription('The message to queue'))
699
- .setRequired(true);
700
- return option;
701
- })
702
- .setDMPermission(false)
703
- .toJSON(),
704
- new SlashCommandBuilder()
705
- .setName('clear-queue')
706
- .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
707
- .setDMPermission(false)
708
- .toJSON(),
709
- new SlashCommandBuilder()
710
- .setName('queue-command')
711
- .setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
712
- .addStringOption((option) => {
713
- option
714
- .setName('command')
715
- .setDescription(truncateCommandDescription('The command to run'))
716
- .setRequired(true)
717
- .setAutocomplete(true);
718
- return option;
719
- })
720
- .addStringOption((option) => {
721
- option
722
- .setName('arguments')
723
- .setDescription(truncateCommandDescription('Arguments to pass to the command'))
724
- .setRequired(false);
725
- return option;
726
- })
727
- .setDMPermission(false)
728
- .toJSON(),
729
- new SlashCommandBuilder()
730
- .setName('undo')
731
- .setDescription(truncateCommandDescription('Undo the last assistant message (revert file changes)'))
732
- .setDMPermission(false)
733
- .toJSON(),
734
- new SlashCommandBuilder()
735
- .setName('redo')
736
- .setDescription(truncateCommandDescription('Redo previously undone changes'))
737
- .setDMPermission(false)
738
- .toJSON(),
739
- new SlashCommandBuilder()
740
- .setName('verbosity')
741
- .setDescription(truncateCommandDescription('Set output verbosity for this channel'))
742
- .setDMPermission(false)
743
- .toJSON(),
744
- new SlashCommandBuilder()
745
- .setName('restart-opencode-server')
746
- .setDescription(truncateCommandDescription('Restart the shared opencode server (fixes state/auth/plugins)'))
747
- .setDMPermission(false)
748
- .toJSON(),
749
- new SlashCommandBuilder()
750
- .setName('run-shell-command')
751
- .setDescription(truncateCommandDescription('Run a shell command in the project directory. Tip: prefix messages with ! as shortcut'))
752
- .addStringOption((option) => {
753
- option
754
- .setName('command')
755
- .setDescription(truncateCommandDescription('Command to run'))
756
- .setRequired(true);
757
- return option;
758
- })
759
- .setDMPermission(false)
760
- .toJSON(),
761
- new SlashCommandBuilder()
762
- .setName('context-usage')
763
- .setDescription(truncateCommandDescription('Show token usage and context window percentage for this session'))
764
- .setDMPermission(false)
765
- .toJSON(),
766
- new SlashCommandBuilder()
767
- .setName('session-id')
768
- .setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
769
- .setDMPermission(false)
770
- .toJSON(),
771
- new SlashCommandBuilder()
772
- .setName('memory-snapshot')
773
- .setDescription(truncateCommandDescription('Write a V8 heap snapshot to disk for memory debugging'))
774
- .setDMPermission(false)
775
- .toJSON(),
776
- new SlashCommandBuilder()
777
- .setName('upgrade-and-restart')
778
- .setDescription(truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'))
779
- .setDMPermission(false)
780
- .toJSON(),
781
- new SlashCommandBuilder()
782
- .setName('transcription-key')
783
- .setDescription(truncateCommandDescription('Set API key for voice message transcription (OpenAI or Gemini)'))
784
- .setDMPermission(false)
785
- .toJSON(),
786
- new SlashCommandBuilder()
787
- .setName('mcp')
788
- .setDescription(truncateCommandDescription('List and manage MCP servers for this project'))
789
- .setDMPermission(false)
790
- .toJSON(),
791
- new SlashCommandBuilder()
792
- .setName('screenshare')
793
- .setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)'))
794
- .setDMPermission(false)
795
- .toJSON(),
796
- new SlashCommandBuilder()
797
- .setName('screenshare-stop')
798
- .setDescription(truncateCommandDescription('Stop screen sharing'))
799
- .setDMPermission(false)
800
- .toJSON(),
801
- ];
802
- // Add user-defined commands with source-based suffixes (-cmd / -skill)
803
- // Also populate registeredUserCommands in the store for /queue-command autocomplete
804
- const newRegisteredCommands = [];
805
- for (const cmd of userCommands) {
806
- if (SKIP_USER_COMMANDS.includes(cmd.name)) {
807
- continue;
808
- }
809
- // Sanitize command name: oh-my-opencode uses MCP commands with colons and slashes,
810
- // which Discord doesn't allow in command names.
811
- // Discord command names: lowercase, alphanumeric and hyphens only, must start with letter/number.
812
- const sanitizedName = cmd.name
813
- .toLowerCase()
814
- .replace(/[:/]/g, '-') // Replace : and / with hyphens first
815
- .replace(/[^a-z0-9-]/g, '-') // Replace any other non-alphanumeric chars
816
- .replace(/-+/g, '-') // Collapse multiple hyphens
817
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
818
- // Skip if sanitized name is empty - would create invalid command name like "-cmd"
819
- if (!sanitizedName) {
820
- continue;
821
- }
822
- const commandSuffix = getDiscordCommandSuffix(cmd);
823
- // Truncate base name before appending suffix so the suffix is never
824
- // lost to Discord's 32-char command name limit.
825
- const baseName = sanitizedName.slice(0, 32 - commandSuffix.length);
826
- const commandName = `${baseName}${commandSuffix}`;
827
- const description = cmd.description || `Run /${cmd.name} command`;
828
- newRegisteredCommands.push({
829
- name: cmd.name,
830
- discordCommandName: commandName,
831
- description,
832
- source: cmd.source,
833
- });
834
- commands.push(new SlashCommandBuilder()
835
- .setName(commandName)
836
- .setDescription(truncateCommandDescription(description))
837
- .addStringOption((option) => {
838
- option
839
- .setName('arguments')
840
- .setDescription(truncateCommandDescription('Arguments to pass to the command'))
841
- .setRequired(false);
842
- return option;
843
- })
844
- .setDMPermission(false)
845
- .toJSON());
846
- }
847
- store.setState({ registeredUserCommands: newRegisteredCommands });
848
- // Add agent-specific quick commands like /plan-agent, /build-agent
849
- // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
850
- const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
851
- for (const agent of primaryAgents) {
852
- const sanitizedName = sanitizeAgentName(agent.name);
853
- // Skip if sanitized name is empty or would create invalid command name
854
- // Discord command names must start with a lowercase letter or number
855
- if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
856
- continue;
857
- }
858
- // Truncate base name before appending suffix so the -agent suffix is never
859
- // lost to Discord's 32-char command name limit.
860
- const agentSuffix = '-agent';
861
- const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length);
862
- const commandName = `${agentBaseName}${agentSuffix}`;
863
- const description = buildQuickAgentCommandDescription({
864
- agentName: agent.name,
865
- description: agent.description,
866
- });
867
- commands.push(new SlashCommandBuilder()
868
- .setName(commandName)
869
- .setDescription(truncateCommandDescription(description))
870
- .setDMPermission(false)
871
- .toJSON());
872
- }
873
- const rest = createDiscordRest(token);
874
- const uniqueGuildIds = Array.from(new Set(guildIds.filter((guildId) => guildId)));
875
- const guildCommandNames = new Set(commands
876
- .map((command) => {
877
- return command.name;
878
- })
879
- .filter((name) => {
880
- return typeof name === 'string';
881
- }));
882
- if (uniqueGuildIds.length === 0) {
883
- cliLogger.warn('COMMANDS: No guilds available, skipping slash command registration');
884
- return;
885
- }
886
- try {
887
- // PUT is a bulk overwrite: Discord matches by name, updates changed fields
888
- // (description, options, etc.) in place, creates new commands, and deletes
889
- // any not present in the body. No local diffing needed.
890
- const results = await Promise.allSettled(uniqueGuildIds.map(async (guildId) => {
891
- const response = await rest.put(Routes.applicationGuildCommands(appId, guildId), {
892
- body: commands,
893
- });
894
- const registeredCount = Array.isArray(response)
895
- ? response.length
896
- : commands.length;
897
- return { guildId, registeredCount };
898
- }));
899
- const failedGuilds = results
900
- .map((result, index) => {
901
- if (result.status === 'fulfilled') {
902
- return null;
903
- }
904
- return {
905
- guildId: uniqueGuildIds[index],
906
- error: result.reason instanceof Error
907
- ? result.reason.message
908
- : String(result.reason),
909
- };
910
- })
911
- .filter((value) => {
912
- return value !== null;
913
- });
914
- if (failedGuilds.length > 0) {
915
- failedGuilds.forEach((failure) => {
916
- cliLogger.warn(`COMMANDS: Failed to register slash commands for guild ${failure.guildId}: ${failure.error}`);
917
- });
918
- throw new Error(`Failed to register slash commands for ${failedGuilds.length} guild(s)`);
919
- }
920
- const successfulGuilds = results.length;
921
- const firstRegisteredCount = results[0];
922
- const registeredCommandCount = firstRegisteredCount && firstRegisteredCount.status === 'fulfilled'
923
- ? firstRegisteredCount.value.registeredCount
924
- : commands.length;
925
- // In gateway mode, global application routes (/applications/{app_id}/commands)
926
- // are denied by the proxy (DeniedWithoutGuild). Legacy global commands only
927
- // exist for self-hosted bots that previously registered commands globally.
928
- const isGateway = store.getState().discordBaseUrl !== 'https://discord.com';
929
- if (!isGateway) {
930
- await deleteLegacyGlobalCommands({
931
- rest,
932
- appId,
933
- commandNames: guildCommandNames,
934
- });
935
- }
936
- cliLogger.info(`COMMANDS: Successfully registered ${registeredCommandCount} slash commands for ${successfulGuilds} guild(s)`);
937
- }
938
- catch (error) {
939
- cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error));
940
- throw error;
941
- }
942
- }
440
+ import { registerCommands, SKIP_USER_COMMANDS } from './discord-command-registration.js';
943
441
  async function reconcileKimakiRole({ guild }) {
944
442
  try {
945
443
  const roles = await guild.roles.fetch();
@@ -1,15 +1,18 @@
1
- // /restart-opencode-server command - Restart the single shared opencode server.
2
- // Used for resolving opencode state issues, internal bugs, refreshing auth state, plugins, etc.
3
- // Aborts in-progress sessions in this channel before restarting. Note: since there is one
4
- // shared server, this restart affects all projects. Other runtimes reconnect through their
5
- // listener backoff loop once the shared server comes back.
1
+ // /restart-opencode-server command - Restart the single shared opencode server
2
+ // and re-register Discord slash commands.
3
+ // Used for resolving opencode state issues, internal bugs, refreshing auth state,
4
+ // plugins, and picking up new/changed slash commands or agents. Aborts in-progress
5
+ // sessions in this channel before restarting. Note: since there is one shared server,
6
+ // this restart affects all projects. Other runtimes reconnect through their listener
7
+ // backoff loop once the shared server comes back.
6
8
  import { ChannelType, MessageFlags, } from 'discord.js';
7
- import { restartOpencodeServer } from '../opencode.js';
9
+ import { initializeOpencodeForDirectory, restartOpencodeServer } from '../opencode.js';
8
10
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
9
11
  import { createLogger, LogPrefix } from '../logger.js';
10
12
  import { disposeRuntimesForDirectory } from '../session-handler/thread-session-runtime.js';
13
+ import { registerCommands } from '../discord-command-registration.js';
11
14
  const logger = createLogger(LogPrefix.OPENCODE);
12
- export async function handleRestartOpencodeServerCommand({ command, }) {
15
+ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
13
16
  const channel = command.channel;
14
17
  if (!channel) {
15
18
  await command.reply({
@@ -68,7 +71,57 @@ export async function handleRestartOpencodeServerCommand({ command, }) {
68
71
  ? ` (aborted ${abortedCount} active session${abortedCount > 1 ? 's' : ''})`
69
72
  : '';
70
73
  await command.editReply({
71
- content: `Opencode server **restarted** successfully${abortMsg}`,
74
+ content: `Opencode server **restarted** successfully${abortMsg}. Re-registering slash commands...`,
72
75
  });
73
76
  logger.log('[RESTART] Shared opencode server restarted');
77
+ // Re-register Discord slash commands after restart so new/changed
78
+ // commands, agents, and plugins are picked up immediately.
79
+ const token = command.client.token;
80
+ if (!token) {
81
+ logger.error('[RESTART] No bot token available, skipping command registration');
82
+ await command.editReply({
83
+ content: `Opencode server **restarted**${abortMsg}, but slash command re-registration skipped (no bot token)`,
84
+ });
85
+ return;
86
+ }
87
+ const guildIds = [...command.client.guilds.cache.keys()];
88
+ const opencodeResult = await initializeOpencodeForDirectory(projectDirectory);
89
+ const [userCommands, agents] = await (async () => {
90
+ if (opencodeResult instanceof Error) {
91
+ logger.warn('[RESTART] OpenCode init failed, registering without user commands:', opencodeResult.message);
92
+ return [[], []];
93
+ }
94
+ const getClient = opencodeResult;
95
+ const [cmds, ags] = await Promise.all([
96
+ getClient()
97
+ .command.list({ directory: projectDirectory })
98
+ .then((r) => r.data || [])
99
+ .catch((e) => {
100
+ logger.warn('[RESTART] Failed to load user commands:', e instanceof Error ? e.stack : String(e));
101
+ return [];
102
+ }),
103
+ getClient()
104
+ .app.agents({ directory: projectDirectory })
105
+ .then((r) => r.data || [])
106
+ .catch((e) => {
107
+ logger.warn('[RESTART] Failed to load agents:', e instanceof Error ? e.stack : String(e));
108
+ return [];
109
+ }),
110
+ ]);
111
+ return [cmds, ags];
112
+ })();
113
+ const registerResult = await registerCommands({ token, appId, guildIds, userCommands, agents })
114
+ .then(() => null)
115
+ .catch((e) => (e instanceof Error ? e : new Error(String(e))));
116
+ if (registerResult instanceof Error) {
117
+ logger.error('[RESTART] Failed to re-register commands:', registerResult.message);
118
+ await command.editReply({
119
+ content: `Opencode server **restarted**${abortMsg}, but slash command re-registration failed: ${registerResult.message}`,
120
+ });
121
+ return;
122
+ }
123
+ logger.log('[RESTART] Slash commands re-registered');
124
+ await command.editReply({
125
+ content: `Opencode server **restarted** and slash commands **re-registered**${abortMsg}`,
126
+ });
74
127
  }
@@ -1,5 +1,5 @@
1
1
  // Utility to condense MEMORY.md into a line-numbered table of contents.
2
- // Separated from opencode-plugin.ts because OpenCode's plugin loader calls
2
+ // Separated from kimaki-opencode-plugin.ts because OpenCode's plugin loader calls
3
3
  // every exported function in the module as a plugin initializer — exporting
4
4
  // this utility from the plugin entry file caused it to be invoked with a
5
5
  // PluginInput object instead of a string, crashing inside marked's Lexer.
@@ -13,7 +13,7 @@
13
13
  // Decision logic is extracted into pure functions that take state + input
14
14
  // and return whether to inject — making them testable without mocking.
15
15
  //
16
- // Exported from opencode-plugin.ts — each export is treated as a separate
16
+ // Exported from kimaki-opencode-plugin.ts — each export is treated as a separate
17
17
  // plugin by OpenCode's plugin loader.
18
18
  import crypto from 'node:crypto';
19
19
  import fs from 'node:fs';