@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,112 @@
1
+ // /abort command - Abort the current OpenCode request in this thread.
2
+
3
+ import {
4
+ ChannelType,
5
+ MessageFlags,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import type { CommandContext } from './types.js'
10
+ import { getThreadSession } from '../database.js'
11
+ import { initializeOpencodeForDirectory } from '../opencode.js'
12
+ import {
13
+ resolveWorkingDirectory,
14
+ SILENT_MESSAGE_FLAGS,
15
+ } from '../discord-utils.js'
16
+ import { abortControllers } from '../session-handler.js'
17
+ import { SessionAbortError } from '../errors.js'
18
+ import { createLogger, LogPrefix } from '../logger.js'
19
+
20
+ const logger = createLogger(LogPrefix.ABORT)
21
+
22
+ export async function handleAbortCommand({
23
+ command,
24
+ }: CommandContext): Promise<void> {
25
+ const channel = command.channel
26
+
27
+ if (!channel) {
28
+ await command.reply({
29
+ content: 'This command can only be used in a channel',
30
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
31
+ })
32
+ return
33
+ }
34
+
35
+ const isThread = [
36
+ ChannelType.PublicThread,
37
+ ChannelType.PrivateThread,
38
+ ChannelType.AnnouncementThread,
39
+ ].includes(channel.type)
40
+
41
+ if (!isThread) {
42
+ await command.reply({
43
+ content:
44
+ 'This command can only be used in a thread with an active session',
45
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
46
+ })
47
+ return
48
+ }
49
+
50
+ const resolved = await resolveWorkingDirectory({
51
+ channel: channel as TextChannel | ThreadChannel,
52
+ })
53
+
54
+ if (!resolved) {
55
+ await command.reply({
56
+ content: 'Could not determine project directory for this channel',
57
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
58
+ })
59
+ return
60
+ }
61
+
62
+ const { projectDirectory } = resolved
63
+
64
+ const sessionId = await getThreadSession(channel.id)
65
+
66
+ if (!sessionId) {
67
+ await command.reply({
68
+ content: 'No active session in this thread',
69
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
70
+ })
71
+ return
72
+ }
73
+
74
+ const existingController = abortControllers.get(sessionId)
75
+ if (existingController) {
76
+ logger.log(
77
+ `[ABORT] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - user ran /abort command`,
78
+ )
79
+ existingController.abort(new SessionAbortError({ reason: 'user-requested' }))
80
+ abortControllers.delete(sessionId)
81
+ }
82
+
83
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
84
+ if (getClient instanceof Error) {
85
+ await command.reply({
86
+ content: `Failed to abort: ${getClient.message}`,
87
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
88
+ })
89
+ return
90
+ }
91
+
92
+ try {
93
+ logger.log(
94
+ `[ABORT-API] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - sending API abort from /abort command`,
95
+ )
96
+ await getClient().session.abort({
97
+ sessionID: sessionId,
98
+ })
99
+
100
+ await command.reply({
101
+ content: `šŸ›‘ Request **aborted**`,
102
+ flags: SILENT_MESSAGE_FLAGS,
103
+ })
104
+ logger.log(`Session ${sessionId} aborted by user`)
105
+ } catch (error) {
106
+ logger.error('[ABORT] Error:', error)
107
+ await command.reply({
108
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
109
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
110
+ })
111
+ }
112
+ }
@@ -0,0 +1,376 @@
1
+ // Action button tool handler - Shows Discord buttons for quick model actions.
2
+ // Used by the kimaki_action_buttons tool to render up to 3 buttons and route
3
+ // button clicks back into the session as a new user message.
4
+
5
+ import {
6
+ ActionRowBuilder,
7
+ ButtonBuilder,
8
+ ButtonStyle,
9
+ MessageFlags,
10
+ type ButtonInteraction,
11
+ type ThreadChannel,
12
+ } from 'discord.js'
13
+ import crypto from 'node:crypto'
14
+ import { getThreadSession } from '../database.js'
15
+ import {
16
+ NOTIFY_MESSAGE_FLAGS,
17
+ resolveWorkingDirectory,
18
+ sendThreadMessage,
19
+ } from '../discord-utils.js'
20
+ import { createLogger } from '../logger.js'
21
+ import { notifyError } from '../sentry.js'
22
+ import {
23
+ abortControllers,
24
+ addToQueue,
25
+ handleOpencodeSession,
26
+ } from '../session-handler.js'
27
+
28
+ const logger = createLogger('ACT_BTN')
29
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000
30
+
31
+ export type ActionButtonColor = 'white' | 'blue' | 'green' | 'red'
32
+
33
+ export type ActionButtonOption = {
34
+ label: string
35
+ color?: ActionButtonColor
36
+ }
37
+
38
+ export type ActionButtonsRequest = {
39
+ sessionId: string
40
+ threadId: string
41
+ directory: string
42
+ buttons: ActionButtonOption[]
43
+ }
44
+
45
+ type PendingActionButtonsContext = {
46
+ sessionId: string
47
+ directory: string
48
+ thread: ThreadChannel
49
+ buttons: ActionButtonOption[]
50
+ contextHash: string
51
+ messageId?: string
52
+ resolved: boolean
53
+ timer: ReturnType<typeof setTimeout>
54
+ }
55
+
56
+ export const pendingActionButtonContexts = new Map<
57
+ string,
58
+ PendingActionButtonsContext
59
+ >()
60
+ const pendingActionButtonRequests = new Map<string, ActionButtonsRequest>()
61
+ const pendingActionButtonRequestWaiters = new Map<
62
+ string,
63
+ (request: ActionButtonsRequest) => void
64
+ >()
65
+
66
+ export function queueActionButtonsRequest(request: ActionButtonsRequest): void {
67
+ pendingActionButtonRequests.set(request.sessionId, request)
68
+ const waiter = pendingActionButtonRequestWaiters.get(request.sessionId)
69
+ if (!waiter) {
70
+ return
71
+ }
72
+ pendingActionButtonRequestWaiters.delete(request.sessionId)
73
+ waiter(request)
74
+ }
75
+
76
+ export async function waitForQueuedActionButtonsRequest({
77
+ sessionId,
78
+ timeoutMs,
79
+ }: {
80
+ sessionId: string
81
+ timeoutMs: number
82
+ }): Promise<ActionButtonsRequest | undefined> {
83
+ const queued = pendingActionButtonRequests.get(sessionId)
84
+ if (queued) {
85
+ pendingActionButtonRequests.delete(sessionId)
86
+ return queued
87
+ }
88
+
89
+ return await new Promise<ActionButtonsRequest | undefined>((resolve) => {
90
+ const timeout = setTimeout(() => {
91
+ const currentWaiter = pendingActionButtonRequestWaiters.get(sessionId)
92
+ if (!currentWaiter || currentWaiter !== onRequest) {
93
+ return
94
+ }
95
+ pendingActionButtonRequestWaiters.delete(sessionId)
96
+ resolve(undefined)
97
+ }, timeoutMs)
98
+
99
+ const onRequest = (request: ActionButtonsRequest) => {
100
+ clearTimeout(timeout)
101
+ pendingActionButtonRequests.delete(sessionId)
102
+ resolve(request)
103
+ }
104
+
105
+ pendingActionButtonRequestWaiters.set(sessionId, onRequest)
106
+ })
107
+ }
108
+
109
+ function toButtonStyle(color?: ActionButtonColor): ButtonStyle {
110
+ if (color === 'blue') {
111
+ return ButtonStyle.Primary
112
+ }
113
+ if (color === 'green') {
114
+ return ButtonStyle.Success
115
+ }
116
+ if (color === 'red') {
117
+ return ButtonStyle.Danger
118
+ }
119
+ return ButtonStyle.Secondary
120
+ }
121
+
122
+ function resolveContext(context: PendingActionButtonsContext): boolean {
123
+ if (context.resolved) {
124
+ return false
125
+ }
126
+ context.resolved = true
127
+ clearTimeout(context.timer)
128
+ pendingActionButtonContexts.delete(context.contextHash)
129
+ return true
130
+ }
131
+
132
+ function updateButtonMessage({
133
+ context,
134
+ status,
135
+ }: {
136
+ context: PendingActionButtonsContext
137
+ status: string
138
+ }): void {
139
+ if (!context.messageId) {
140
+ return
141
+ }
142
+ context.thread.messages
143
+ .fetch(context.messageId)
144
+ .then((message) => {
145
+ return message.edit({
146
+ content: `**Action Required**\n${status}`,
147
+ components: [],
148
+ })
149
+ })
150
+ .catch(() => {})
151
+ }
152
+
153
+ async function sendClickedActionToModel({
154
+ interaction,
155
+ thread,
156
+ prompt,
157
+ }: {
158
+ interaction: ButtonInteraction
159
+ thread: ThreadChannel
160
+ prompt: string
161
+ }): Promise<void> {
162
+ const resolved = await resolveWorkingDirectory({ channel: thread })
163
+ if (!resolved) {
164
+ throw new Error('Could not resolve project directory for thread')
165
+ }
166
+
167
+ const sessionId = await getThreadSession(thread.id)
168
+ const existingController = sessionId ? abortControllers.get(sessionId) : null
169
+ const hasActiveRequest = Boolean(
170
+ existingController && !existingController.signal.aborted,
171
+ )
172
+ const username = interaction.user.globalName || interaction.user.username
173
+
174
+ if (hasActiveRequest) {
175
+ addToQueue({
176
+ threadId: thread.id,
177
+ message: {
178
+ prompt,
179
+ userId: interaction.user.id,
180
+ username,
181
+ queuedAt: Date.now(),
182
+ appId: resolved.channelAppId,
183
+ },
184
+ })
185
+ logger.log(`[ACTION] Queued click for session ${sessionId}`)
186
+ return
187
+ }
188
+
189
+ await handleOpencodeSession({
190
+ prompt,
191
+ thread,
192
+ projectDirectory: resolved.projectDirectory,
193
+ channelId: thread.parentId || thread.id,
194
+ username,
195
+ userId: interaction.user.id,
196
+ appId: resolved.channelAppId,
197
+ })
198
+ }
199
+
200
+ export async function showActionButtons({
201
+ thread,
202
+ sessionId,
203
+ directory,
204
+ buttons,
205
+ }: {
206
+ thread: ThreadChannel
207
+ sessionId: string
208
+ directory: string
209
+ buttons: ActionButtonOption[]
210
+ }): Promise<void> {
211
+ const safeButtons = buttons
212
+ .slice(0, 3)
213
+ .map((button) => {
214
+ return {
215
+ label: button.label.trim().slice(0, 80),
216
+ color: button.color,
217
+ }
218
+ })
219
+ .filter((button) => {
220
+ return button.label.length > 0
221
+ })
222
+
223
+ if (safeButtons.length === 0) {
224
+ throw new Error('No valid buttons to display')
225
+ }
226
+
227
+ const contextHash = crypto.randomBytes(8).toString('hex')
228
+ const timer = setTimeout(() => {
229
+ const current = pendingActionButtonContexts.get(contextHash)
230
+ if (!current || current.resolved) {
231
+ return
232
+ }
233
+ resolveContext(current)
234
+ updateButtonMessage({ context: current, status: '_Expired_' })
235
+ }, PENDING_TTL_MS)
236
+
237
+ const context: PendingActionButtonsContext = {
238
+ sessionId,
239
+ directory,
240
+ thread,
241
+ buttons: safeButtons,
242
+ contextHash,
243
+ resolved: false,
244
+ timer,
245
+ }
246
+
247
+ pendingActionButtonContexts.set(contextHash, context)
248
+
249
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
250
+ ...safeButtons.map((button, index) => {
251
+ return new ButtonBuilder()
252
+ .setCustomId(`action_button:${contextHash}:${index}`)
253
+ .setLabel(button.label)
254
+ .setStyle(toButtonStyle(button.color))
255
+ }),
256
+ )
257
+
258
+ try {
259
+ const message = await thread.send({
260
+ content: '**Action Required**',
261
+ components: [row],
262
+ flags: NOTIFY_MESSAGE_FLAGS,
263
+ })
264
+
265
+ context.messageId = message.id
266
+ logger.log(
267
+ `Showed ${safeButtons.length} action button(s) for session ${sessionId}`,
268
+ )
269
+ } catch (error) {
270
+ clearTimeout(timer)
271
+ pendingActionButtonContexts.delete(contextHash)
272
+ throw new Error('Failed to send action buttons', { cause: error })
273
+ }
274
+ }
275
+
276
+ export async function handleActionButton(
277
+ interaction: ButtonInteraction,
278
+ ): Promise<void> {
279
+ const customId = interaction.customId
280
+ if (!customId.startsWith('action_button:')) {
281
+ return
282
+ }
283
+
284
+ const [, contextHash, indexPart] = customId.split(':')
285
+ if (!contextHash || !indexPart) {
286
+ await interaction.reply({
287
+ content: 'Invalid action button.',
288
+ flags: MessageFlags.Ephemeral,
289
+ })
290
+ return
291
+ }
292
+
293
+ const context = pendingActionButtonContexts.get(contextHash)
294
+ if (!context || context.resolved) {
295
+ await interaction.reply({
296
+ content: 'This action is no longer available.',
297
+ flags: MessageFlags.Ephemeral,
298
+ })
299
+ return
300
+ }
301
+
302
+ const buttonIndex = Number.parseInt(indexPart, 10)
303
+ const button = context.buttons[buttonIndex]
304
+ if (!button) {
305
+ await interaction.reply({
306
+ content: 'This action is no longer available.',
307
+ flags: MessageFlags.Ephemeral,
308
+ })
309
+ return
310
+ }
311
+
312
+ await interaction.deferUpdate()
313
+ const claimed = resolveContext(context)
314
+ if (!claimed) {
315
+ return
316
+ }
317
+
318
+ const thread = interaction.channel
319
+ if (!thread?.isThread()) {
320
+ logger.warn('[ACTION] Button clicked outside thread channel')
321
+ await interaction.editReply({
322
+ content: '**Action Required**\n_This action is no longer available._',
323
+ components: [],
324
+ })
325
+ return
326
+ }
327
+
328
+ const currentSessionId = await getThreadSession(thread.id)
329
+ if (!currentSessionId || currentSessionId !== context.sessionId) {
330
+ await interaction.editReply({
331
+ content: '**Action Required**\n_Expired due to session change._',
332
+ components: [],
333
+ })
334
+ return
335
+ }
336
+
337
+ await interaction.editReply({
338
+ content: `**Action Required**\n_Selected: ${button.label}_`,
339
+ components: [],
340
+ })
341
+
342
+ const prompt = `User clicked: ${button.label}`
343
+
344
+ try {
345
+ await sendClickedActionToModel({
346
+ interaction,
347
+ thread,
348
+ prompt,
349
+ })
350
+ } catch (error) {
351
+ logger.error('[ACTION] Failed to send click to model:', error)
352
+ void notifyError(error, 'Action button click send to model failed')
353
+ await sendThreadMessage(
354
+ thread,
355
+ `Failed to send action click: ${error instanceof Error ? error.message : String(error)}`,
356
+ )
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Dismiss pending action buttons for a thread (e.g. user sent a new message).
362
+ * Removes buttons from the message and cleans up context.
363
+ */
364
+ export function cancelPendingActionButtons(threadId: string): boolean {
365
+ for (const [, ctx] of pendingActionButtonContexts) {
366
+ if (ctx.thread.id !== threadId) {
367
+ continue
368
+ }
369
+ if (!resolveContext(ctx)) {
370
+ continue
371
+ }
372
+ updateButtonMessage({ context: ctx, status: '_Buttons dismissed._' })
373
+ return true
374
+ }
375
+ return false
376
+ }
@@ -0,0 +1,152 @@
1
+ // /add-project command - Create Discord channels for an existing OpenCode project.
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import type { CommandContext, AutocompleteContext } from './types.js'
6
+ import {
7
+ findChannelsByDirectory,
8
+ getAllTextChannelDirectories,
9
+ } from '../database.js'
10
+ import { initializeOpencodeForDirectory } from '../opencode.js'
11
+ import { createProjectChannels } from '../channel-management.js'
12
+ import { createLogger, LogPrefix } from '../logger.js'
13
+ import { abbreviatePath } from '../utils.js'
14
+ import * as errore from 'errore'
15
+
16
+ const logger = createLogger(LogPrefix.ADD_PROJECT)
17
+
18
+ export async function handleAddProjectCommand({
19
+ command,
20
+ appId,
21
+ }: CommandContext): Promise<void> {
22
+ await command.deferReply({ ephemeral: false })
23
+
24
+ const projectId = command.options.getString('project', true)
25
+ const guild = command.guild
26
+
27
+ if (!guild) {
28
+ await command.editReply('This command can only be used in a guild')
29
+ return
30
+ }
31
+
32
+ try {
33
+ const currentDir = process.cwd()
34
+ const getClient = await initializeOpencodeForDirectory(currentDir)
35
+ if (getClient instanceof Error) {
36
+ await command.editReply(getClient.message)
37
+ return
38
+ }
39
+
40
+ const projectsResponse = await getClient().project.list({})
41
+ if (!projectsResponse.data) {
42
+ await command.editReply('Failed to fetch projects')
43
+ return
44
+ }
45
+
46
+ const project = projectsResponse.data.find((p) => p.id === projectId)
47
+
48
+ if (!project) {
49
+ await command.editReply('Project not found')
50
+ return
51
+ }
52
+
53
+ const directory = project.worktree
54
+
55
+ if (!fs.existsSync(directory)) {
56
+ await command.editReply(`Directory does not exist: ${directory}`)
57
+ return
58
+ }
59
+
60
+ const existingChannels = await findChannelsByDirectory({
61
+ directory,
62
+ channelType: 'text',
63
+ })
64
+
65
+ if (existingChannels.length > 0) {
66
+ await command.editReply(
67
+ `A channel already exists for this directory: <#${existingChannels[0]!.channel_id}>`,
68
+ )
69
+ return
70
+ }
71
+
72
+ const { textChannelId, voiceChannelId, channelName } =
73
+ await createProjectChannels({
74
+ guild,
75
+ projectDirectory: directory,
76
+ appId,
77
+ botName: command.client.user?.username,
78
+ })
79
+
80
+ const voiceInfo = voiceChannelId ? `\nšŸ”Š Voice: <#${voiceChannelId}>` : ''
81
+ await command.editReply(
82
+ `āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>${voiceInfo}\nšŸ“ Directory: \`${directory}\``,
83
+ )
84
+
85
+ logger.log(`Created channels for project ${channelName} at ${directory}`)
86
+ } catch (error) {
87
+ logger.error('[ADD-PROJECT] Error:', error)
88
+ await command.editReply(
89
+ `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
90
+ )
91
+ }
92
+ }
93
+
94
+ export async function handleAddProjectAutocomplete({
95
+ interaction,
96
+ appId,
97
+ }: AutocompleteContext): Promise<void> {
98
+ const focusedValue = interaction.options.getFocused()
99
+
100
+ try {
101
+ const currentDir = process.cwd()
102
+ const getClient = await initializeOpencodeForDirectory(currentDir)
103
+ if (getClient instanceof Error) {
104
+ await interaction.respond([])
105
+ return
106
+ }
107
+
108
+ const projectsResponse = await getClient().project.list({})
109
+ if (!projectsResponse.data) {
110
+ await interaction.respond([])
111
+ return
112
+ }
113
+
114
+ const existingDirs = await getAllTextChannelDirectories()
115
+ const existingDirSet = new Set(existingDirs)
116
+
117
+ const availableProjects = projectsResponse.data.filter((project) => {
118
+ if (existingDirSet.has(project.worktree)) {
119
+ return false
120
+ }
121
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
122
+ return false
123
+ }
124
+ return true
125
+ })
126
+
127
+ const projects = availableProjects
128
+ .filter((project) => {
129
+ const baseName = path.basename(project.worktree)
130
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase()
131
+ return searchText.includes(focusedValue.toLowerCase())
132
+ })
133
+ .sort((a, b) => {
134
+ const aTime = a.time.initialized || a.time.created
135
+ const bTime = b.time.initialized || b.time.created
136
+ return bTime - aTime
137
+ })
138
+ .slice(0, 25)
139
+ .map((project) => {
140
+ const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`
141
+ return {
142
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
143
+ value: project.id,
144
+ }
145
+ })
146
+
147
+ await interaction.respond(projects)
148
+ } catch (error) {
149
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
150
+ await interaction.respond([])
151
+ }
152
+ }