kimaki 0.4.78 → 0.4.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@
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 {
7
8
  intro,
8
9
  outro,
@@ -39,6 +40,7 @@ import {
39
40
  } from './discord-bot.js'
40
41
  import {
41
42
  getBotTokenWithMode,
43
+ ensureServiceAuthToken,
42
44
  setBotToken,
43
45
  setBotMode,
44
46
  setChannelDirectory,
@@ -50,6 +52,8 @@ import {
50
52
  createScheduledTask,
51
53
  listScheduledTasks,
52
54
  cancelScheduledTask,
55
+ getScheduledTask,
56
+ updateScheduledTask,
53
57
  getSessionStartSourcesBySessionIds,
54
58
  } from './database.js'
55
59
  import { ShareMarkdown } from './markdown.js'
@@ -83,7 +87,7 @@ import {
83
87
  SlashCommandBuilder,
84
88
  AttachmentBuilder,
85
89
  } from 'discord.js'
86
- import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl } from './discord-urls.js'
90
+ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js'
87
91
  import crypto from 'node:crypto'
88
92
  import path from 'node:path'
89
93
  import fs from 'node:fs'
@@ -117,9 +121,9 @@ import {
117
121
  import { startHranaServer } from './hrana-server.js'
118
122
  import { startIpcPolling, stopIpcPolling } from './ipc-polling.js'
119
123
  import {
120
- getLocalTimeZone,
121
124
  getPromptPreview,
122
125
  parseSendAtValue,
126
+ parseScheduledTaskPayload,
123
127
  serializeScheduledTaskPayload,
124
128
  type ParsedSendAt,
125
129
  type ScheduledTaskPayload,
@@ -248,7 +252,37 @@ async function sendDiscordMessageWithOptionalAttachment({
248
252
  fs.mkdirSync(tmpDir, { recursive: true })
249
253
  }
250
254
  const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`)
251
- fs.writeFileSync(tmpFile, prompt)
255
+ // Wrap long lines so the file is readable in Discord's preview
256
+ // (Discord doesn't wrap text in file attachments)
257
+ const wrappedPrompt = prompt
258
+ .split('\n')
259
+ .flatMap((line) => {
260
+ if (line.length <= 120) {
261
+ return [line]
262
+ }
263
+ const wrapped: string[] = []
264
+ let remaining = line
265
+ const maxCol = 120
266
+ // Only soft-break at a space if it's reasonably close to maxCol,
267
+ // otherwise hard-break to avoid tiny fragments from early spaces
268
+ const minSoftBreak = 90
269
+ while (remaining.length > maxCol) {
270
+ const lastSpace = remaining.lastIndexOf(' ', maxCol)
271
+ const useSoftBreak = lastSpace >= minSoftBreak
272
+ const breakAt = useSoftBreak ? lastSpace : maxCol
273
+ wrapped.push(remaining.slice(0, breakAt))
274
+ // Only consume the separator space on soft breaks
275
+ remaining = useSoftBreak
276
+ ? remaining.slice(breakAt + 1)
277
+ : remaining.slice(breakAt)
278
+ }
279
+ if (remaining.length > 0) {
280
+ wrapped.push(remaining)
281
+ }
282
+ return wrapped
283
+ })
284
+ .join('\n')
285
+ fs.writeFileSync(tmpFile, wrappedPrompt)
252
286
 
253
287
  try {
254
288
  const formData = new FormData()
@@ -543,7 +577,7 @@ async function ensureCommandAvailable({
543
577
  cliLogger.log(`Failed to install ${name}`)
544
578
  cliLogger.error(
545
579
  'Installation error:',
546
- error instanceof Error ? error.message : String(error),
580
+ error instanceof Error ? error.stack : String(error),
547
581
  )
548
582
  process.exit(EXIT_NO_RESTART)
549
583
  }
@@ -603,16 +637,18 @@ async function ensureCommandAvailable({
603
637
  // Run opencode upgrade in the background so the user always has the latest version.
604
638
 
605
639
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
606
- // Not detached, so it dies automatically with the parent process.
640
+ // Uses -w to watch the parent PID so caffeinate self-terminates if kimaki
641
+ // exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
607
642
  function startCaffeinate() {
608
643
  if (process.platform !== 'darwin') {
609
644
  return
610
645
  }
611
646
  try {
612
- const proc = spawn('caffeinate', ['-i'], {
647
+ const proc = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
613
648
  stdio: 'ignore',
614
649
  detached: false,
615
650
  })
651
+ proc.unref()
616
652
  proc.on('error', (err) => {
617
653
  cliLogger.warn('Failed to start caffeinate:', err.message)
618
654
  })
@@ -740,11 +776,17 @@ async function deleteLegacyGlobalCommands({
740
776
  }
741
777
  } catch (error) {
742
778
  cliLogger.warn(
743
- `COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.message : String(error)}`,
779
+ `COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.stack : String(error)}`,
744
780
  )
745
781
  }
746
782
  }
747
783
 
784
+ // Discord slash command descriptions must be 1-100 chars.
785
+ // Truncate to 100 so @sapphire/shapeshift validation never throws.
786
+ function truncateCommandDescription(description: string): string {
787
+ return description.slice(0, 100)
788
+ }
789
+
748
790
  async function registerCommands({
749
791
  token,
750
792
  appId,
@@ -761,11 +803,11 @@ async function registerCommands({
761
803
  const commands = [
762
804
  new SlashCommandBuilder()
763
805
  .setName('resume')
764
- .setDescription('Resume an existing OpenCode session')
806
+ .setDescription(truncateCommandDescription('Resume an existing OpenCode session'))
765
807
  .addStringOption((option) => {
766
808
  option
767
809
  .setName('session')
768
- .setDescription('The session to resume')
810
+ .setDescription(truncateCommandDescription('The session to resume'))
769
811
  .setRequired(true)
770
812
  .setAutocomplete(true)
771
813
 
@@ -775,11 +817,11 @@ async function registerCommands({
775
817
  .toJSON(),
776
818
  new SlashCommandBuilder()
777
819
  .setName('new-session')
778
- .setDescription('Start a new OpenCode session')
820
+ .setDescription(truncateCommandDescription('Start a new OpenCode session'))
779
821
  .addStringOption((option) => {
780
822
  option
781
823
  .setName('prompt')
782
- .setDescription('Prompt content for the session')
824
+ .setDescription(truncateCommandDescription('Prompt content for the session'))
783
825
  .setRequired(true)
784
826
 
785
827
  return option
@@ -788,7 +830,7 @@ async function registerCommands({
788
830
  option
789
831
  .setName('files')
790
832
  .setDescription(
791
- 'Files to mention (comma or space separated; autocomplete)',
833
+ truncateCommandDescription('Files to mention (comma or space separated; autocomplete)'),
792
834
  )
793
835
  .setAutocomplete(true)
794
836
  .setMaxLength(6000)
@@ -798,7 +840,7 @@ async function registerCommands({
798
840
  .addStringOption((option) => {
799
841
  option
800
842
  .setName('agent')
801
- .setDescription('Agent to use for this session')
843
+ .setDescription(truncateCommandDescription('Agent to use for this session'))
802
844
  .setAutocomplete(true)
803
845
 
804
846
  return option
@@ -808,13 +850,13 @@ async function registerCommands({
808
850
  new SlashCommandBuilder()
809
851
  .setName('new-worktree')
810
852
  .setDescription(
811
- 'Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.',
853
+ truncateCommandDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.'),
812
854
  )
813
855
  .addStringOption((option) => {
814
856
  option
815
857
  .setName('name')
816
858
  .setDescription(
817
- 'Name for worktree (optional in threads - uses thread name)',
859
+ truncateCommandDescription('Name for worktree (optional in threads - uses thread name)'),
818
860
  )
819
861
  .setRequired(false)
820
862
 
@@ -824,7 +866,7 @@ async function registerCommands({
824
866
  option
825
867
  .setName('base-branch')
826
868
  .setDescription(
827
- 'Branch to create the worktree from (default: origin/HEAD or main)',
869
+ truncateCommandDescription('Branch to create the worktree from (default: origin/HEAD or main)'),
828
870
  )
829
871
  .setRequired(false)
830
872
  .setAutocomplete(true)
@@ -836,13 +878,13 @@ async function registerCommands({
836
878
  new SlashCommandBuilder()
837
879
  .setName('merge-worktree')
838
880
  .setDescription(
839
- 'Squash-merge worktree into the default branch. Optionally pick a target branch.',
881
+ truncateCommandDescription('Squash-merge worktree into default branch. Aborts if main has uncommitted changes.'),
840
882
  )
841
883
  .addStringOption((option) => {
842
884
  option
843
885
  .setName('target-branch')
844
886
  .setDescription(
845
- 'Branch to merge into (default: origin/HEAD or main)',
887
+ truncateCommandDescription('Branch to merge into (default: origin/HEAD or main)'),
846
888
  )
847
889
  .setRequired(false)
848
890
  .setAutocomplete(true)
@@ -854,32 +896,44 @@ async function registerCommands({
854
896
  new SlashCommandBuilder()
855
897
  .setName('toggle-worktrees')
856
898
  .setDescription(
857
- 'Toggle automatic git worktree creation for new sessions in this channel',
899
+ truncateCommandDescription('Toggle automatic git worktree creation for new sessions in this channel'),
858
900
  )
859
901
  .setDMPermission(false)
860
902
  .toJSON(),
861
903
  new SlashCommandBuilder()
862
904
  .setName('worktrees')
863
- .setDescription('List all active worktree sessions')
905
+ .setDescription(truncateCommandDescription('List all active worktree sessions'))
906
+ .setDMPermission(false)
907
+ .toJSON(),
908
+ new SlashCommandBuilder()
909
+ .setName('tasks')
910
+ .setDescription(truncateCommandDescription('List scheduled tasks created via send --send-at'))
911
+ .addBooleanOption((option) => {
912
+ return option
913
+ .setName('all')
914
+ .setDescription(
915
+ truncateCommandDescription('Include completed, cancelled, and failed tasks'),
916
+ )
917
+ })
864
918
  .setDMPermission(false)
865
919
  .toJSON(),
866
920
  new SlashCommandBuilder()
867
921
  .setName('toggle-mention-mode')
868
922
  .setDescription(
869
- 'Toggle mention-only mode (bot only responds when @mentioned)',
923
+ truncateCommandDescription('Toggle mention-only mode (bot only responds when @mentioned)'),
870
924
  )
871
925
  .setDMPermission(false)
872
926
  .toJSON(),
873
927
  new SlashCommandBuilder()
874
928
  .setName('add-project')
875
929
  .setDescription(
876
- 'Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects',
930
+ truncateCommandDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects'),
877
931
  )
878
932
  .addStringOption((option) => {
879
933
  option
880
934
  .setName('project')
881
935
  .setDescription(
882
- 'Recent OpenCode projects. Use `npx kimaki project add` if not listed',
936
+ truncateCommandDescription('Recent OpenCode projects. Use `npx kimaki project add` if not listed'),
883
937
  )
884
938
  .setRequired(true)
885
939
  .setAutocomplete(true)
@@ -890,11 +944,11 @@ async function registerCommands({
890
944
  .toJSON(),
891
945
  new SlashCommandBuilder()
892
946
  .setName('remove-project')
893
- .setDescription('Remove Discord channels for a project')
947
+ .setDescription(truncateCommandDescription('Remove Discord channels for a project'))
894
948
  .addStringOption((option) => {
895
949
  option
896
950
  .setName('project')
897
- .setDescription('Select a project to remove')
951
+ .setDescription(truncateCommandDescription('Select a project to remove'))
898
952
  .setRequired(true)
899
953
  .setAutocomplete(true)
900
954
 
@@ -905,12 +959,12 @@ async function registerCommands({
905
959
  new SlashCommandBuilder()
906
960
  .setName('create-new-project')
907
961
  .setDescription(
908
- 'Create a new project folder, initialize git, and start a session',
962
+ truncateCommandDescription('Create a new project folder, initialize git, and start a session'),
909
963
  )
910
964
  .addStringOption((option) => {
911
965
  option
912
966
  .setName('name')
913
- .setDescription('Name for the new project folder')
967
+ .setDescription(truncateCommandDescription('Name for the new project folder'))
914
968
  .setRequired(true)
915
969
 
916
970
  return option
@@ -919,74 +973,74 @@ async function registerCommands({
919
973
  .toJSON(),
920
974
  new SlashCommandBuilder()
921
975
  .setName('abort')
922
- .setDescription('Abort the current OpenCode request in this thread')
976
+ .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
923
977
  .setDMPermission(false)
924
978
  .toJSON(),
925
979
  new SlashCommandBuilder()
926
980
  .setName('compact')
927
981
  .setDescription(
928
- 'Compact the session context by summarizing conversation history',
982
+ truncateCommandDescription('Compact the session context by summarizing conversation history'),
929
983
  )
930
984
  .setDMPermission(false)
931
985
  .toJSON(),
932
986
  new SlashCommandBuilder()
933
987
  .setName('stop')
934
- .setDescription('Abort the current OpenCode request in this thread')
988
+ .setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
935
989
  .setDMPermission(false)
936
990
  .toJSON(),
937
991
  new SlashCommandBuilder()
938
992
  .setName('share')
939
- .setDescription('Share the current session as a public URL')
993
+ .setDescription(truncateCommandDescription('Share the current session as a public URL'))
940
994
  .setDMPermission(false)
941
995
  .toJSON(),
942
996
  new SlashCommandBuilder()
943
997
  .setName('diff')
944
- .setDescription('Show git diff as a shareable URL')
998
+ .setDescription(truncateCommandDescription('Show git diff as a shareable URL'))
945
999
  .setDMPermission(false)
946
1000
  .toJSON(),
947
1001
  new SlashCommandBuilder()
948
1002
  .setName('fork')
949
- .setDescription('Fork the session from a past user message')
1003
+ .setDescription(truncateCommandDescription('Fork the session from a past user message'))
950
1004
  .setDMPermission(false)
951
1005
  .toJSON(),
952
1006
  new SlashCommandBuilder()
953
1007
  .setName('model')
954
- .setDescription('Set the preferred model for this channel or session')
1008
+ .setDescription(truncateCommandDescription('Set the preferred model for this channel or session'))
955
1009
  .setDMPermission(false)
956
1010
  .toJSON(),
957
1011
  new SlashCommandBuilder()
958
1012
  .setName('model-variant')
959
1013
  .setDescription(
960
- 'Quickly change the thinking level variant for the current model',
1014
+ truncateCommandDescription('Quickly change the thinking level variant for the current model'),
961
1015
  )
962
1016
  .setDMPermission(false)
963
1017
  .toJSON(),
964
1018
  new SlashCommandBuilder()
965
1019
  .setName('unset-model-override')
966
- .setDescription('Remove model override and use default instead')
1020
+ .setDescription(truncateCommandDescription('Remove model override and use default instead'))
967
1021
  .setDMPermission(false)
968
1022
  .toJSON(),
969
1023
  new SlashCommandBuilder()
970
1024
  .setName('login')
971
1025
  .setDescription(
972
- 'Authenticate with an AI provider (OAuth or API key). Use this instead of /connect',
1026
+ truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'),
973
1027
  )
974
1028
  .setDMPermission(false)
975
1029
  .toJSON(),
976
1030
  new SlashCommandBuilder()
977
1031
  .setName('agent')
978
- .setDescription('Set the preferred agent for this channel or session')
1032
+ .setDescription(truncateCommandDescription('Set the preferred agent for this channel or session'))
979
1033
  .setDMPermission(false)
980
1034
  .toJSON(),
981
1035
  new SlashCommandBuilder()
982
1036
  .setName('queue')
983
1037
  .setDescription(
984
- 'Queue a message to be sent after the current response finishes',
1038
+ truncateCommandDescription('Queue a message to be sent after the current response finishes'),
985
1039
  )
986
1040
  .addStringOption((option) => {
987
1041
  option
988
1042
  .setName('message')
989
- .setDescription('The message to queue')
1043
+ .setDescription(truncateCommandDescription('The message to queue'))
990
1044
  .setRequired(true)
991
1045
 
992
1046
  return option
@@ -995,18 +1049,18 @@ async function registerCommands({
995
1049
  .toJSON(),
996
1050
  new SlashCommandBuilder()
997
1051
  .setName('clear-queue')
998
- .setDescription('Clear all queued messages in this thread')
1052
+ .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
999
1053
  .setDMPermission(false)
1000
1054
  .toJSON(),
1001
1055
  new SlashCommandBuilder()
1002
1056
  .setName('queue-command')
1003
1057
  .setDescription(
1004
- 'Queue a user command to run after the current response finishes',
1058
+ truncateCommandDescription('Queue a user command to run after the current response finishes'),
1005
1059
  )
1006
1060
  .addStringOption((option) => {
1007
1061
  option
1008
1062
  .setName('command')
1009
- .setDescription('The command to run')
1063
+ .setDescription(truncateCommandDescription('The command to run'))
1010
1064
  .setRequired(true)
1011
1065
  .setAutocomplete(true)
1012
1066
  return option
@@ -1014,7 +1068,7 @@ async function registerCommands({
1014
1068
  .addStringOption((option) => {
1015
1069
  option
1016
1070
  .setName('arguments')
1017
- .setDescription('Arguments to pass to the command')
1071
+ .setDescription(truncateCommandDescription('Arguments to pass to the command'))
1018
1072
  .setRequired(false)
1019
1073
  return option
1020
1074
  })
@@ -1022,35 +1076,35 @@ async function registerCommands({
1022
1076
  .toJSON(),
1023
1077
  new SlashCommandBuilder()
1024
1078
  .setName('undo')
1025
- .setDescription('Undo the last assistant message (revert file changes)')
1079
+ .setDescription(truncateCommandDescription('Undo the last assistant message (revert file changes)'))
1026
1080
  .setDMPermission(false)
1027
1081
  .toJSON(),
1028
1082
  new SlashCommandBuilder()
1029
1083
  .setName('redo')
1030
- .setDescription('Redo previously undone changes')
1084
+ .setDescription(truncateCommandDescription('Redo previously undone changes'))
1031
1085
  .setDMPermission(false)
1032
1086
  .toJSON(),
1033
1087
  new SlashCommandBuilder()
1034
1088
  .setName('verbosity')
1035
- .setDescription('Set output verbosity for this channel')
1089
+ .setDescription(truncateCommandDescription('Set output verbosity for this channel'))
1036
1090
  .setDMPermission(false)
1037
1091
  .toJSON(),
1038
1092
  new SlashCommandBuilder()
1039
1093
  .setName('restart-opencode-server')
1040
1094
  .setDescription(
1041
- 'Restart the shared opencode server (fixes state/auth/plugins)',
1095
+ truncateCommandDescription('Restart the shared opencode server (fixes state/auth/plugins)'),
1042
1096
  )
1043
1097
  .setDMPermission(false)
1044
1098
  .toJSON(),
1045
1099
  new SlashCommandBuilder()
1046
1100
  .setName('run-shell-command')
1047
1101
  .setDescription(
1048
- 'Run a shell command in the project directory. Tip: prefix messages with ! as shortcut',
1102
+ truncateCommandDescription('Run a shell command in the project directory. Tip: prefix messages with ! as shortcut'),
1049
1103
  )
1050
1104
  .addStringOption((option) => {
1051
1105
  option
1052
1106
  .setName('command')
1053
- .setDescription('Command to run')
1107
+ .setDescription(truncateCommandDescription('Command to run'))
1054
1108
  .setRequired(true)
1055
1109
  return option
1056
1110
  })
@@ -1059,44 +1113,44 @@ async function registerCommands({
1059
1113
  new SlashCommandBuilder()
1060
1114
  .setName('context-usage')
1061
1115
  .setDescription(
1062
- 'Show token usage and context window percentage for this session',
1116
+ truncateCommandDescription('Show token usage and context window percentage for this session'),
1063
1117
  )
1064
1118
  .setDMPermission(false)
1065
1119
  .toJSON(),
1066
1120
  new SlashCommandBuilder()
1067
1121
  .setName('session-id')
1068
1122
  .setDescription(
1069
- 'Show current session ID and opencode attach command for this thread',
1123
+ truncateCommandDescription('Show current session ID and opencode attach command for this thread'),
1070
1124
  )
1071
1125
  .setDMPermission(false)
1072
1126
  .toJSON(),
1073
1127
  new SlashCommandBuilder()
1074
1128
  .setName('upgrade-and-restart')
1075
1129
  .setDescription(
1076
- 'Upgrade kimaki to the latest version and restart the bot',
1130
+ truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'),
1077
1131
  )
1078
1132
  .setDMPermission(false)
1079
1133
  .toJSON(),
1080
1134
  new SlashCommandBuilder()
1081
1135
  .setName('transcription-key')
1082
1136
  .setDescription(
1083
- 'Set API key for voice message transcription (OpenAI or Gemini)',
1137
+ truncateCommandDescription('Set API key for voice message transcription (OpenAI or Gemini)'),
1084
1138
  )
1085
1139
  .setDMPermission(false)
1086
1140
  .toJSON(),
1087
1141
  new SlashCommandBuilder()
1088
1142
  .setName('mcp')
1089
- .setDescription('List and manage MCP servers for this project')
1143
+ .setDescription(truncateCommandDescription('List and manage MCP servers for this project'))
1090
1144
  .setDMPermission(false)
1091
1145
  .toJSON(),
1092
1146
  new SlashCommandBuilder()
1093
1147
  .setName('screenshare')
1094
- .setDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)')
1148
+ .setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)'))
1095
1149
  .setDMPermission(false)
1096
1150
  .toJSON(),
1097
1151
  new SlashCommandBuilder()
1098
1152
  .setName('screenshare-stop')
1099
- .setDescription('Stop screen sharing')
1153
+ .setDescription(truncateCommandDescription('Stop screen sharing'))
1100
1154
  .setDMPermission(false)
1101
1155
  .toJSON(),
1102
1156
  ]
@@ -1142,11 +1196,11 @@ async function registerCommands({
1142
1196
  commands.push(
1143
1197
  new SlashCommandBuilder()
1144
1198
  .setName(commandName)
1145
- .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
1199
+ .setDescription(truncateCommandDescription(description))
1146
1200
  .addStringOption((option) => {
1147
1201
  option
1148
1202
  .setName('arguments')
1149
- .setDescription('Arguments to pass to the command')
1203
+ .setDescription(truncateCommandDescription('Arguments to pass to the command'))
1150
1204
  .setRequired(false)
1151
1205
  return option
1152
1206
  })
@@ -1181,7 +1235,7 @@ async function registerCommands({
1181
1235
  commands.push(
1182
1236
  new SlashCommandBuilder()
1183
1237
  .setName(commandName)
1184
- .setDescription(description)
1238
+ .setDescription(truncateCommandDescription(description))
1185
1239
  .setDMPermission(false)
1186
1240
  .toJSON(),
1187
1241
  )
@@ -1470,7 +1524,7 @@ async function ensureDefaultChannelsWithWelcome({
1470
1524
  }
1471
1525
  } catch (error) {
1472
1526
  cliLogger.warn(
1473
- `Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
1527
+ `Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.stack : String(error)}`,
1474
1528
  )
1475
1529
  }
1476
1530
  }
@@ -1516,7 +1570,7 @@ async function backgroundInit({
1516
1570
  .catch((error) => {
1517
1571
  cliLogger.warn(
1518
1572
  'Failed to load user commands during background init:',
1519
- error instanceof Error ? error.message : String(error),
1573
+ error instanceof Error ? error.stack : String(error),
1520
1574
  )
1521
1575
  return []
1522
1576
  }),
@@ -1526,7 +1580,7 @@ async function backgroundInit({
1526
1580
  .catch((error) => {
1527
1581
  cliLogger.warn(
1528
1582
  'Failed to load agents during background init:',
1529
- error instanceof Error ? error.message : String(error),
1583
+ error instanceof Error ? error.stack : String(error),
1530
1584
  )
1531
1585
  return []
1532
1586
  }),
@@ -1537,7 +1591,7 @@ async function backgroundInit({
1537
1591
  } catch (error) {
1538
1592
  cliLogger.error(
1539
1593
  'Background init failed:',
1540
- error instanceof Error ? error.message : String(error),
1594
+ error instanceof Error ? error.stack : String(error),
1541
1595
  )
1542
1596
  void notifyError(error, 'Background init failed')
1543
1597
  }
@@ -1689,6 +1743,7 @@ async function resolveCredentials({
1689
1743
  clientId,
1690
1744
  clientSecret,
1691
1745
  gatewayCallbackUrl,
1746
+ reachableUrl: getInternetReachableBaseUrl() || undefined,
1692
1747
  })
1693
1748
  if (oauthUrlResult instanceof Error) {
1694
1749
  throw oauthUrlResult
@@ -1894,33 +1949,35 @@ async function run({
1894
1949
  const forceRestartOnboarding = Boolean(restartOnboarding)
1895
1950
  const forceGateway = Boolean(gateway)
1896
1951
 
1897
- // Step 0: Ensure required CLI tools are installed (OpenCode + Bun)
1898
- await ensureCommandAvailable({
1899
- name: 'opencode',
1900
- envPathKey: 'OPENCODE_PATH',
1901
- installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1902
- installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1903
- possiblePathsUnix: [
1904
- '~/.local/bin/opencode',
1905
- '~/.opencode/bin/opencode',
1906
- '/usr/local/bin/opencode',
1907
- '/opt/opencode/bin/opencode',
1908
- ],
1909
- possiblePathsWindows: [
1910
- '~\\.local\\bin\\opencode.exe',
1911
- '~\\AppData\\Local\\opencode\\opencode.exe',
1912
- '~\\.opencode\\bin\\opencode.exe',
1913
- ],
1914
- })
1915
-
1916
- await ensureCommandAvailable({
1917
- name: 'bun',
1918
- envPathKey: 'BUN_PATH',
1919
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
1920
- installWindows: 'irm bun.sh/install.ps1 | iex',
1921
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1922
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1923
- })
1952
+ // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
1953
+ // Run checks in parallel since they're independent `which` calls.
1954
+ await Promise.all([
1955
+ ensureCommandAvailable({
1956
+ name: 'opencode',
1957
+ envPathKey: 'OPENCODE_PATH',
1958
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1959
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1960
+ possiblePathsUnix: [
1961
+ '~/.local/bin/opencode',
1962
+ '~/.opencode/bin/opencode',
1963
+ '/usr/local/bin/opencode',
1964
+ '/opt/opencode/bin/opencode',
1965
+ ],
1966
+ possiblePathsWindows: [
1967
+ '~\\.local\\bin\\opencode.exe',
1968
+ '~\\AppData\\Local\\opencode\\opencode.exe',
1969
+ '~\\.opencode\\bin\\opencode.exe',
1970
+ ],
1971
+ }),
1972
+ ensureCommandAvailable({
1973
+ name: 'bun',
1974
+ envPathKey: 'BUN_PATH',
1975
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
1976
+ installWindows: 'irm bun.sh/install.ps1 | iex',
1977
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1978
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1979
+ }),
1980
+ ])
1924
1981
 
1925
1982
 
1926
1983
  backgroundUpgradeKimaki()
@@ -1931,6 +1988,7 @@ async function run({
1931
1988
  // don't work. CLI subcommands skip the server and use file: directly.
1932
1989
  const hranaResult = await startHranaServer({
1933
1990
  dbPath: path.join(getDataDir(), 'discord-sessions.db'),
1991
+ bindAll: getInternetReachableBaseUrl() !== null,
1934
1992
  })
1935
1993
  if (hranaResult instanceof Error) {
1936
1994
  cliLogger.error('Failed to start hrana server:', hranaResult.message)
@@ -1946,6 +2004,14 @@ async function run({
1946
2004
  gatewayCallbackUrl,
1947
2005
  })
1948
2006
 
2007
+ const gatewayToken = await ensureServiceAuthToken({
2008
+ appId,
2009
+ preferredGatewayToken: isGatewayMode ? token : undefined,
2010
+ })
2011
+ // Always set service auth token so local and internet control-plane paths
2012
+ // share one auth model (/kimaki/wake and future service endpoints).
2013
+ store.setState({ gatewayToken })
2014
+
1949
2015
  // In gateway mode, ensure REST calls route through the gateway proxy.
1950
2016
  // getBotTokenWithMode() sets this for saved-credential paths, but the fresh
1951
2017
  // onboarding path returns directly without going through getBotTokenWithMode(),
@@ -1956,6 +2022,34 @@ async function run({
1956
2022
  store.setState({ discordBaseUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL })
1957
2023
  }
1958
2024
 
2025
+ // When KIMAKI_INTERNET_REACHABLE_URL is set, the hrana server exposes
2026
+ // a /kimaki/wake endpoint for the gateway-proxy to wake this instance and
2027
+ // wait until discord.js is connected. Keep Discord traffic on the normal
2028
+ // configured base URL (gateway-proxy in gateway mode).
2029
+ if (getInternetReachableBaseUrl()) {
2030
+ cliLogger.log('Internet-reachable mode: enabling /kimaki/wake endpoint on hrana server')
2031
+ }
2032
+
2033
+ // Start OpenCode server as early as possible — non-blocking.
2034
+ // All dependencies are met (dataDir, lockPort, gatewayToken, hranaUrl set).
2035
+ // Runs in parallel with last_used_at update, skipChannelSetup check, and
2036
+ // Discord Gateway login so cold start is not blocked by OpenCode spawn.
2037
+ const currentDir = process.cwd()
2038
+ cliLogger.log('Starting OpenCode server...')
2039
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then(
2040
+ (result) => {
2041
+ if (result instanceof Error) {
2042
+ throw new Error(result.message)
2043
+ }
2044
+ cliLogger.log('OpenCode server ready!')
2045
+ return result
2046
+ },
2047
+ )
2048
+ // Prevent unhandled rejection if OpenCode fails before backgroundInit
2049
+ // or the channel setup path awaits it. Errors are handled by the
2050
+ // respective consumers (backgroundInit catches, channel setup re-throws).
2051
+ opencodePromise.catch(() => {})
2052
+
1959
2053
  // Mark this bot as the most recently used so subcommands in separate
1960
2054
  // processes (send, upload-to-discord, project list) pick the correct bot.
1961
2055
  // getBotTokenWithMode() orders by last_used_at DESC as cross-process
@@ -1995,19 +2089,6 @@ async function run({
1995
2089
  return true
1996
2090
  })()
1997
2091
 
1998
- // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
1999
- // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
2000
- const currentDir = process.cwd()
2001
- cliLogger.log('Starting OpenCode server...')
2002
- const opencodePromise = initializeOpencodeForDirectory(currentDir).then(
2003
- (result) => {
2004
- if (result instanceof Error) {
2005
- throw new Error(result.message)
2006
- }
2007
- return result
2008
- },
2009
- )
2010
-
2011
2092
  cliLogger.log(`Connecting to ${getDiscordRestApiUrl()}...`)
2012
2093
  const discordClient = await createDiscordClient()
2013
2094
 
@@ -2066,7 +2147,7 @@ async function run({
2066
2147
  } catch (error) {
2067
2148
  cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway)
2068
2149
  cliLogger.error(
2069
- 'Error: ' + (error instanceof Error ? error.message : String(error)),
2150
+ 'Error: ' + (error instanceof Error ? error.stack : String(error)),
2070
2151
  )
2071
2152
  process.exit(EXIT_NO_RESTART)
2072
2153
  }
@@ -2126,7 +2207,7 @@ async function run({
2126
2207
  } catch (error) {
2127
2208
  cliLogger.warn(
2128
2209
  'Background channel sync failed:',
2129
- error instanceof Error ? error.message : String(error),
2210
+ error instanceof Error ? error.stack : String(error),
2130
2211
  )
2131
2212
  }
2132
2213
 
@@ -2143,7 +2224,7 @@ async function run({
2143
2224
  } catch (error) {
2144
2225
  cliLogger.warn(
2145
2226
  'Background default channel creation failed:',
2146
- error instanceof Error ? error.message : String(error),
2227
+ error instanceof Error ? error.stack : String(error),
2147
2228
  )
2148
2229
  }
2149
2230
  })()
@@ -2184,7 +2265,6 @@ async function run({
2184
2265
  // Wait for OpenCode, fetch projects, show prompts, create channels if needed
2185
2266
  cliLogger.log('Waiting for OpenCode server...')
2186
2267
  const getClient = await opencodePromise
2187
- cliLogger.log('OpenCode server ready!')
2188
2268
 
2189
2269
  cliLogger.log('Fetching OpenCode data...')
2190
2270
 
@@ -2197,7 +2277,7 @@ async function run({
2197
2277
  cliLogger.log('Failed to fetch projects')
2198
2278
  cliLogger.error(
2199
2279
  'Error:',
2200
- error instanceof Error ? error.message : String(error),
2280
+ error instanceof Error ? error.stack : String(error),
2201
2281
  )
2202
2282
  discordClient.destroy()
2203
2283
  process.exit(EXIT_NO_RESTART)
@@ -2208,7 +2288,7 @@ async function run({
2208
2288
  .catch((error) => {
2209
2289
  cliLogger.warn(
2210
2290
  'Failed to load user commands during setup:',
2211
- error instanceof Error ? error.message : String(error),
2291
+ error instanceof Error ? error.stack : String(error),
2212
2292
  )
2213
2293
  return []
2214
2294
  }),
@@ -2218,7 +2298,7 @@ async function run({
2218
2298
  .catch((error) => {
2219
2299
  cliLogger.warn(
2220
2300
  'Failed to load agents during setup:',
2221
- error instanceof Error ? error.message : String(error),
2301
+ error instanceof Error ? error.stack : String(error),
2222
2302
  )
2223
2303
  return []
2224
2304
  }),
@@ -2375,7 +2455,7 @@ async function run({
2375
2455
  .catch((error) => {
2376
2456
  cliLogger.error(
2377
2457
  'Failed to register slash commands:',
2378
- error instanceof Error ? error.message : String(error),
2458
+ error instanceof Error ? error.stack : String(error),
2379
2459
  )
2380
2460
  })
2381
2461
 
@@ -2594,7 +2674,7 @@ cli
2594
2674
  } catch (error) {
2595
2675
  cliLogger.error(
2596
2676
  'Error:',
2597
- error instanceof Error ? error.message : String(error),
2677
+ error instanceof Error ? error.stack : String(error),
2598
2678
  )
2599
2679
  process.exit(EXIT_NO_RESTART)
2600
2680
  }
@@ -2649,7 +2729,7 @@ cli
2649
2729
  } catch (error) {
2650
2730
  cliLogger.error(
2651
2731
  'Error:',
2652
- error instanceof Error ? error.message : String(error),
2732
+ error instanceof Error ? error.stack : String(error),
2653
2733
  )
2654
2734
  process.exit(EXIT_NO_RESTART)
2655
2735
  }
@@ -2799,7 +2879,7 @@ cli
2799
2879
  } catch (error) {
2800
2880
  cliLogger.error(
2801
2881
  'Error:',
2802
- error instanceof Error ? error.message : String(error),
2882
+ error instanceof Error ? error.stack : String(error),
2803
2883
  )
2804
2884
  process.exit(EXIT_NO_RESTART)
2805
2885
  }
@@ -2853,7 +2933,7 @@ cli
2853
2933
  } catch (error) {
2854
2934
  cliLogger.error(
2855
2935
  'Error:',
2856
- error instanceof Error ? error.message : String(error),
2936
+ error instanceof Error ? error.stack : String(error),
2857
2937
  )
2858
2938
  process.exit(EXIT_NO_RESTART)
2859
2939
  }
@@ -2924,7 +3004,7 @@ cli
2924
3004
  } catch (error) {
2925
3005
  cliLogger.error(
2926
3006
  'Error:',
2927
- error instanceof Error ? error.message : String(error),
3007
+ error instanceof Error ? error.stack : String(error),
2928
3008
  )
2929
3009
  process.exit(EXIT_NO_RESTART)
2930
3010
  }
@@ -2961,6 +3041,13 @@ cli
2961
3041
  .option('-u, --user <username>', 'Discord username to add to thread')
2962
3042
  .option('--agent <agent>', 'Agent to use for the session')
2963
3043
  .option('--model <model>', 'Model to use (format: provider/model)')
3044
+ .option(
3045
+ '--permission <rule>',
3046
+ z.array(z.string()).describe(
3047
+ 'Session permission rule (repeatable). Format: "tool:action" or "tool:pattern:action". ' +
3048
+ 'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"',
3049
+ ),
3050
+ )
2964
3051
  .option(
2965
3052
  '--send-at <schedule>',
2966
3053
  'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)',
@@ -2986,6 +3073,7 @@ cli
2986
3073
  user?: string
2987
3074
  agent?: string
2988
3075
  model?: string
3076
+ permission?: string[]
2989
3077
  sendAt?: string
2990
3078
  thread?: string
2991
3079
  session?: string
@@ -3045,10 +3133,12 @@ cli
3045
3133
  if (!sendAt) {
3046
3134
  return null
3047
3135
  }
3136
+ // Cron expressions use UTC so the schedule is consistent regardless of
3137
+ // which machine runs the bot. The system message tells the model to use UTC.
3048
3138
  return parseSendAtValue({
3049
3139
  value: sendAt,
3050
3140
  now: new Date(),
3051
- timezone: getLocalTimeZone(),
3141
+ timezone: 'UTC',
3052
3142
  })
3053
3143
  })()
3054
3144
  if (parsedSchedule instanceof Error) {
@@ -3190,7 +3280,7 @@ cli
3190
3280
  } catch (error) {
3191
3281
  cliLogger.debug(
3192
3282
  'Failed to fetch existing channel while selecting guild:',
3193
- error instanceof Error ? error.message : String(error),
3283
+ error instanceof Error ? error.stack : String(error),
3194
3284
  )
3195
3285
  }
3196
3286
  }
@@ -3282,6 +3372,7 @@ cli
3282
3372
  model: options.model || null,
3283
3373
  username: null,
3284
3374
  userId: null,
3375
+ permissions: options.permission?.length ? options.permission : null,
3285
3376
  }
3286
3377
  const taskId = await createScheduledTask({
3287
3378
  scheduleKind: parsedSchedule.scheduleKind,
@@ -3308,6 +3399,7 @@ cli
3308
3399
 
3309
3400
  const threadPromptMarker: ThreadStartMarker = {
3310
3401
  cliThreadPrompt: true,
3402
+ ...(options.permission?.length ? { permissions: options.permission } : {}),
3311
3403
  }
3312
3404
  const promptEmbed = [
3313
3405
  {
@@ -3439,6 +3531,7 @@ cli
3439
3531
  model: options.model || null,
3440
3532
  username: resolvedUser?.username || null,
3441
3533
  userId: resolvedUser?.id || null,
3534
+ permissions: options.permission?.length ? options.permission : null,
3442
3535
  }
3443
3536
  const taskId = await createScheduledTask({
3444
3537
  scheduleKind: parsedSchedule.scheduleKind,
@@ -3474,6 +3567,7 @@ cli
3474
3567
  }),
3475
3568
  ...(options.agent && { agent: options.agent }),
3476
3569
  ...(options.model && { model: options.model }),
3570
+ ...(options.permission?.length && { permissions: options.permission }),
3477
3571
  }
3478
3572
  const autoStartEmbed = embedMarker
3479
3573
  ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
@@ -3532,7 +3626,7 @@ cli
3532
3626
  } catch (error) {
3533
3627
  cliLogger.error(
3534
3628
  'Error:',
3535
- error instanceof Error ? error.message : String(error),
3629
+ error instanceof Error ? error.stack : String(error),
3536
3630
  )
3537
3631
  process.exit(EXIT_NO_RESTART)
3538
3632
  }
@@ -3583,7 +3677,7 @@ cli
3583
3677
  } catch (error) {
3584
3678
  cliLogger.error(
3585
3679
  'Error:',
3586
- error instanceof Error ? error.message : String(error),
3680
+ error instanceof Error ? error.stack : String(error),
3587
3681
  )
3588
3682
  process.exit(EXIT_NO_RESTART)
3589
3683
  }
@@ -3611,7 +3705,100 @@ cli
3611
3705
  } catch (error) {
3612
3706
  cliLogger.error(
3613
3707
  'Error:',
3614
- error instanceof Error ? error.message : String(error),
3708
+ error instanceof Error ? error.stack : String(error),
3709
+ )
3710
+ process.exit(EXIT_NO_RESTART)
3711
+ }
3712
+ })
3713
+
3714
+ cli
3715
+ .command('task edit <id>', 'Edit prompt or schedule of a planned task')
3716
+ .option('--prompt <prompt>', 'New prompt text')
3717
+ .option('--send-at <sendAt>', 'New schedule (UTC ISO date or cron expression)')
3718
+ .action(async (id: string, options: { prompt?: string; sendAt?: string }) => {
3719
+ try {
3720
+ const trimmedPrompt =
3721
+ options.prompt === undefined ? undefined : options.prompt.trim()
3722
+
3723
+ if (!trimmedPrompt && !options.sendAt) {
3724
+ cliLogger.error('Provide at least --prompt or --send-at')
3725
+ process.exit(EXIT_NO_RESTART)
3726
+ }
3727
+ if (trimmedPrompt !== undefined && trimmedPrompt.length === 0) {
3728
+ cliLogger.error('--prompt cannot be empty')
3729
+ process.exit(EXIT_NO_RESTART)
3730
+ }
3731
+ if (trimmedPrompt !== undefined && trimmedPrompt.length > 1900) {
3732
+ cliLogger.error('--prompt currently supports up to 1900 characters')
3733
+ process.exit(EXIT_NO_RESTART)
3734
+ }
3735
+
3736
+ const taskId = Number.parseInt(id, 10)
3737
+ if (Number.isNaN(taskId) || taskId < 1) {
3738
+ cliLogger.error(`Invalid task ID: ${id}`)
3739
+ process.exit(EXIT_NO_RESTART)
3740
+ }
3741
+
3742
+ await initDatabase()
3743
+ const task = await getScheduledTask(taskId)
3744
+ if (!task) {
3745
+ cliLogger.error(`Task ${taskId} not found`)
3746
+ process.exit(EXIT_NO_RESTART)
3747
+ }
3748
+ if (task.status !== 'planned') {
3749
+ cliLogger.error(
3750
+ `Task ${taskId} is ${task.status}, only planned tasks can be edited`,
3751
+ )
3752
+ process.exit(EXIT_NO_RESTART)
3753
+ }
3754
+
3755
+ const existingPayload = parseScheduledTaskPayload(task.payload_json)
3756
+ if (existingPayload instanceof Error) {
3757
+ cliLogger.error(`Failed to parse task payload: ${existingPayload.message}`)
3758
+ process.exit(EXIT_NO_RESTART)
3759
+ }
3760
+
3761
+ const newPrompt = trimmedPrompt ?? existingPayload.prompt
3762
+ const updatedPayload: ScheduledTaskPayload = {
3763
+ ...existingPayload,
3764
+ prompt: newPrompt,
3765
+ }
3766
+
3767
+ const updateData: Parameters<typeof updateScheduledTask>[0] = {
3768
+ taskId,
3769
+ payloadJson: serializeScheduledTaskPayload(updatedPayload),
3770
+ promptPreview: getPromptPreview(newPrompt),
3771
+ }
3772
+
3773
+ if (options.sendAt) {
3774
+ const parsed = parseSendAtValue({
3775
+ value: options.sendAt,
3776
+ now: new Date(),
3777
+ timezone: 'UTC',
3778
+ })
3779
+ if (parsed instanceof Error) {
3780
+ cliLogger.error(`Invalid --send-at: ${parsed.message}`)
3781
+ process.exit(EXIT_NO_RESTART)
3782
+ }
3783
+ updateData.scheduleKind = parsed.scheduleKind
3784
+ updateData.runAt = parsed.runAt
3785
+ updateData.cronExpr = parsed.cronExpr
3786
+ updateData.timezone = parsed.timezone
3787
+ updateData.nextRunAt = parsed.nextRunAt
3788
+ }
3789
+
3790
+ const updated = await updateScheduledTask(updateData)
3791
+ if (!updated) {
3792
+ cliLogger.error(`Task ${taskId} could not be updated (status may have changed)`)
3793
+ process.exit(EXIT_NO_RESTART)
3794
+ }
3795
+
3796
+ cliLogger.log(`Updated task ${taskId}`)
3797
+ process.exit(0)
3798
+ } catch (error) {
3799
+ cliLogger.error(
3800
+ 'Error:',
3801
+ error instanceof Error ? error.stack : String(error),
3615
3802
  )
3616
3803
  process.exit(EXIT_NO_RESTART)
3617
3804
  }
@@ -3703,7 +3890,7 @@ cli
3703
3890
  } catch (error) {
3704
3891
  cliLogger.debug(
3705
3892
  'Failed to fetch existing channel while selecting guild:',
3706
- error instanceof Error ? error.message : String(error),
3893
+ error instanceof Error ? error.stack : String(error),
3707
3894
  )
3708
3895
  let firstGuild = client.guilds.cache.first()
3709
3896
  if (!firstGuild) {
@@ -3763,14 +3950,14 @@ cli
3763
3950
  } catch (error) {
3764
3951
  cliLogger.debug(
3765
3952
  `Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`,
3766
- error instanceof Error ? error.message : String(error),
3953
+ error instanceof Error ? error.stack : String(error),
3767
3954
  )
3768
3955
  }
3769
3956
  }
3770
3957
  } catch (error) {
3771
3958
  cliLogger.debug(
3772
3959
  'Database lookup failed while checking existing channels:',
3773
- error instanceof Error ? error.message : String(error),
3960
+ error instanceof Error ? error.stack : String(error),
3774
3961
  )
3775
3962
  }
3776
3963
 
@@ -4096,7 +4283,7 @@ cli
4096
4283
  } catch (error) {
4097
4284
  cliLogger.error(
4098
4285
  'Error:',
4099
- error instanceof Error ? error.message : String(error),
4286
+ error instanceof Error ? error.stack : String(error),
4100
4287
  )
4101
4288
  process.exit(EXIT_NO_RESTART)
4102
4289
  }
@@ -4276,7 +4463,7 @@ cli
4276
4463
  } catch (error) {
4277
4464
  cliLogger.error(
4278
4465
  'Error:',
4279
- error instanceof Error ? error.message : String(error),
4466
+ error instanceof Error ? error.stack : String(error),
4280
4467
  )
4281
4468
  process.exit(EXIT_NO_RESTART)
4282
4469
  }
@@ -4350,7 +4537,7 @@ cli
4350
4537
  } catch (error) {
4351
4538
  cliLogger.error(
4352
4539
  'Error:',
4353
- error instanceof Error ? error.message : String(error),
4540
+ error instanceof Error ? error.stack : String(error),
4354
4541
  )
4355
4542
  process.exit(EXIT_NO_RESTART)
4356
4543
  }
@@ -4559,7 +4746,7 @@ cli
4559
4746
  } catch (error) {
4560
4747
  cliLogger.error(
4561
4748
  'Error:',
4562
- error instanceof Error ? error.message : String(error),
4749
+ error instanceof Error ? error.stack : String(error),
4563
4750
  )
4564
4751
  process.exit(EXIT_NO_RESTART)
4565
4752
  }
@@ -4752,12 +4939,47 @@ cli
4752
4939
  } catch (error) {
4753
4940
  cliLogger.error(
4754
4941
  'Error:',
4755
- error instanceof Error ? error.message : String(error),
4942
+ error instanceof Error ? error.stack : String(error),
4756
4943
  )
4757
4944
  process.exit(EXIT_NO_RESTART)
4758
4945
  }
4759
4946
  })
4760
4947
 
4948
+ cli
4949
+ .command(
4950
+ 'session discord-url <sessionId>',
4951
+ 'Print the Discord thread URL for a session',
4952
+ )
4953
+ .option('--json', 'Output as JSON')
4954
+ .action(async (sessionId, options) => {
4955
+ await initDatabase()
4956
+ const threadId = await getThreadIdBySessionId(sessionId)
4957
+ if (!threadId) {
4958
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`)
4959
+ process.exit(EXIT_NO_RESTART)
4960
+ }
4961
+ const { token: botToken } = await resolveBotCredentials()
4962
+ const rest = createDiscordRest(botToken)
4963
+ const threadData = (await rest.get(Routes.channel(threadId))) as {
4964
+ id: string
4965
+ guild_id: string
4966
+ name?: string
4967
+ }
4968
+ const url = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
4969
+ if (options.json) {
4970
+ console.log(JSON.stringify({
4971
+ url,
4972
+ threadId: threadData.id,
4973
+ guildId: threadData.guild_id,
4974
+ sessionId,
4975
+ threadName: threadData.name,
4976
+ }))
4977
+ } else {
4978
+ console.log(url)
4979
+ }
4980
+ process.exit(0)
4981
+ })
4982
+
4761
4983
  cli
4762
4984
  .command(
4763
4985
  'upgrade',
@@ -4795,7 +5017,7 @@ cli
4795
5017
  } catch (error) {
4796
5018
  cliLogger.error(
4797
5019
  'Upgrade failed:',
4798
- error instanceof Error ? error.message : String(error),
5020
+ error instanceof Error ? error.stack : String(error),
4799
5021
  )
4800
5022
  process.exit(EXIT_NO_RESTART)
4801
5023
  }
@@ -4897,7 +5119,7 @@ cli
4897
5119
  } catch (error) {
4898
5120
  cliLogger.error(
4899
5121
  'Merge failed:',
4900
- error instanceof Error ? error.message : String(error),
5122
+ error instanceof Error ? error.stack : String(error),
4901
5123
  )
4902
5124
  process.exit(EXIT_NO_RESTART)
4903
5125
  }