@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,261 @@
1
+ // Permission button handler - Shows buttons for permission requests.
2
+ // When OpenCode asks for permission, this module renders 3 buttons:
3
+ // Accept, Accept Always, and Deny.
4
+
5
+ import {
6
+ ButtonBuilder,
7
+ ButtonStyle,
8
+ type ButtonInteraction,
9
+ ActionRowBuilder,
10
+ type ThreadChannel,
11
+ } from 'discord.js'
12
+ import crypto from 'node:crypto'
13
+ import type { PermissionRequest } from '@opencode-ai/sdk/v2'
14
+ import { getOpencodeClient } from '../opencode.js'
15
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
16
+ import { createLogger, LogPrefix } from '../logger.js'
17
+
18
+ const logger = createLogger(LogPrefix.PERMISSIONS)
19
+
20
+ function wildcardMatch({
21
+ value,
22
+ pattern,
23
+ }: {
24
+ value: string
25
+ pattern: string
26
+ }): boolean {
27
+ let escapedPattern = pattern
28
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
29
+ .replace(/\*/g, '.*')
30
+ .replace(/\?/g, '.')
31
+
32
+ if (escapedPattern.endsWith(' .*')) {
33
+ escapedPattern = escapedPattern.slice(0, -3) + '( .*)?'
34
+ }
35
+
36
+ return new RegExp(`^${escapedPattern}$`, 's').test(value)
37
+ }
38
+
39
+ export function arePatternsCoveredBy({
40
+ patterns,
41
+ coveringPatterns,
42
+ }: {
43
+ patterns: string[]
44
+ coveringPatterns: string[]
45
+ }): boolean {
46
+ return patterns.every((pattern) => {
47
+ return coveringPatterns.some((coveringPattern) => {
48
+ return wildcardMatch({ value: pattern, pattern: coveringPattern })
49
+ })
50
+ })
51
+ }
52
+
53
+ export function compactPermissionPatterns(patterns: string[]): string[] {
54
+ const uniquePatterns = Array.from(new Set(patterns))
55
+ return uniquePatterns.filter((pattern, index) => {
56
+ return !uniquePatterns.some((candidate, candidateIndex) => {
57
+ if (candidateIndex === index) {
58
+ return false
59
+ }
60
+ return wildcardMatch({ value: pattern, pattern: candidate })
61
+ })
62
+ })
63
+ }
64
+
65
+ type PendingPermissionContext = {
66
+ permission: PermissionRequest
67
+ requestIds: string[]
68
+ directory: string
69
+ permissionDirectory: string
70
+ thread: ThreadChannel
71
+ contextHash: string
72
+ }
73
+
74
+ // Store pending permission contexts by hash
75
+ export const pendingPermissionContexts = new Map<
76
+ string,
77
+ PendingPermissionContext
78
+ >()
79
+
80
+ /**
81
+ * Show permission buttons for a permission request.
82
+ * Displays 3 buttons in a row: Accept, Accept Always, Deny.
83
+ * Returns the message ID and context hash for tracking.
84
+ */
85
+ export async function showPermissionButtons({
86
+ thread,
87
+ permission,
88
+ directory,
89
+ permissionDirectory,
90
+ subtaskLabel,
91
+ }: {
92
+ thread: ThreadChannel
93
+ permission: PermissionRequest
94
+ directory: string
95
+ permissionDirectory: string
96
+ subtaskLabel?: string
97
+ }): Promise<{ messageId: string; contextHash: string }> {
98
+ const contextHash = crypto.randomBytes(8).toString('hex')
99
+
100
+ const context: PendingPermissionContext = {
101
+ permission,
102
+ requestIds: [permission.id],
103
+ directory,
104
+ permissionDirectory,
105
+ thread,
106
+ contextHash,
107
+ }
108
+
109
+ pendingPermissionContexts.set(contextHash, context)
110
+
111
+ const patternStr = compactPermissionPatterns(permission.patterns).join(', ')
112
+
113
+ // Build 3 buttons for permission actions
114
+ const acceptButton = new ButtonBuilder()
115
+ .setCustomId(`permission_once:${contextHash}`)
116
+ .setLabel('Accept')
117
+ .setStyle(ButtonStyle.Success)
118
+
119
+ const acceptAlwaysButton = new ButtonBuilder()
120
+ .setCustomId(`permission_always:${contextHash}`)
121
+ .setLabel('Accept Always')
122
+ .setStyle(ButtonStyle.Success)
123
+
124
+ const denyButton = new ButtonBuilder()
125
+ .setCustomId(`permission_reject:${contextHash}`)
126
+ .setLabel('Deny')
127
+ .setStyle(ButtonStyle.Secondary)
128
+
129
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
130
+ acceptButton,
131
+ acceptAlwaysButton,
132
+ denyButton,
133
+ )
134
+
135
+ const subtaskLine = subtaskLabel ? `**From:** \`${subtaskLabel}\`\n` : ''
136
+ const fullContent =
137
+ `⚠️ **Permission Required**\n` +
138
+ subtaskLine +
139
+ `**Type:** \`${permission.permission}\`\n` +
140
+ (patternStr ? `**Pattern:** \`${patternStr}\`` : '')
141
+ const permissionMessage = await thread.send({
142
+ content: fullContent.slice(0, 1900),
143
+ components: [actionRow],
144
+ flags: NOTIFY_MESSAGE_FLAGS,
145
+ })
146
+
147
+ logger.log(`Showed permission buttons for ${permission.id}`)
148
+
149
+ return { messageId: permissionMessage.id, contextHash }
150
+ }
151
+
152
+ /**
153
+ * Handle button click for permission.
154
+ */
155
+ export async function handlePermissionButton(
156
+ interaction: ButtonInteraction,
157
+ ): Promise<void> {
158
+ const customId = interaction.customId
159
+
160
+ // Extract action and hash from customId (e.g., "permission_once:abc123")
161
+ const [actionPart, contextHash] = customId.split(':')
162
+ if (!actionPart || !contextHash) {
163
+ return
164
+ }
165
+
166
+ const response = actionPart.replace('permission_', '') as
167
+ | 'once'
168
+ | 'always'
169
+ | 'reject'
170
+
171
+ const context = pendingPermissionContexts.get(contextHash)
172
+
173
+ if (!context) {
174
+ await interaction.update({ components: [] })
175
+ return
176
+ }
177
+
178
+ await interaction.deferUpdate()
179
+
180
+ try {
181
+ const permClient = getOpencodeClient(context.directory)
182
+ if (!permClient) {
183
+ throw new Error('OpenCode server not found for directory')
184
+ }
185
+ const requestIds =
186
+ context.requestIds.length > 0
187
+ ? context.requestIds
188
+ : [context.permission.id]
189
+ await Promise.all(
190
+ requestIds.map((requestId) => {
191
+ return permClient.permission.reply({
192
+ requestID: requestId,
193
+ directory: context.permissionDirectory,
194
+ reply: response,
195
+ })
196
+ }),
197
+ )
198
+
199
+ pendingPermissionContexts.delete(contextHash)
200
+
201
+ // Update message: show result and remove dropdown
202
+ const resultText = (() => {
203
+ switch (response) {
204
+ case 'once':
205
+ return '✅ Permission **accepted**'
206
+ case 'always':
207
+ return '✅ Permission **accepted** (auto-approve similar requests)'
208
+ case 'reject':
209
+ return '❌ Permission **rejected**'
210
+ }
211
+ })()
212
+
213
+ const patternStr = compactPermissionPatterns(
214
+ context.permission.patterns,
215
+ ).join(', ')
216
+ await interaction.editReply({
217
+ content:
218
+ `⚠️ **Permission Required**\n` +
219
+ `**Type:** \`${context.permission.permission}\`\n` +
220
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
221
+ resultText,
222
+ components: [], // Remove the buttons
223
+ })
224
+
225
+ logger.log(
226
+ `Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`,
227
+ )
228
+ } catch (error) {
229
+ logger.error('Error handling permission:', error)
230
+ await interaction.editReply({
231
+ content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
232
+ components: [],
233
+ })
234
+ }
235
+ }
236
+
237
+ export function addPermissionRequestToContext({
238
+ contextHash,
239
+ requestId,
240
+ }: {
241
+ contextHash: string
242
+ requestId: string
243
+ }): boolean {
244
+ const context = pendingPermissionContexts.get(contextHash)
245
+ if (!context) {
246
+ return false
247
+ }
248
+ if (context.requestIds.includes(requestId)) {
249
+ return false
250
+ }
251
+ context.requestIds = [...context.requestIds, requestId]
252
+ pendingPermissionContexts.set(contextHash, context)
253
+ return true
254
+ }
255
+
256
+ /**
257
+ * Clean up a pending permission context (e.g., on auto-reject).
258
+ */
259
+ export function cleanupPermissionContext(contextHash: string): void {
260
+ pendingPermissionContexts.delete(contextHash)
261
+ }
@@ -0,0 +1,296 @@
1
+ // Queue commands - /queue, /queue-command, /clear-queue
2
+
3
+ import { ChannelType, MessageFlags, type ThreadChannel } from 'discord.js'
4
+ import type { AutocompleteContext, CommandContext } from './types.js'
5
+ import { getThreadSession } from '../database.js'
6
+ import {
7
+ resolveWorkingDirectory,
8
+ sendThreadMessage,
9
+ SILENT_MESSAGE_FLAGS,
10
+ } from '../discord-utils.js'
11
+ import {
12
+ handleOpencodeSession,
13
+ abortControllers,
14
+ addToQueue,
15
+ getQueueLength,
16
+ clearQueue,
17
+ queueOrSendMessage,
18
+ } from '../session-handler.js'
19
+ import { createLogger, LogPrefix } from '../logger.js'
20
+ import { notifyError } from '../sentry.js'
21
+ import { registeredUserCommands } from '../config.js'
22
+
23
+ const logger = createLogger(LogPrefix.QUEUE)
24
+
25
+ export async function handleQueueCommand({
26
+ command,
27
+ appId,
28
+ }: CommandContext): Promise<void> {
29
+ const message = command.options.getString('message', true)
30
+ const channel = command.channel
31
+
32
+ if (!channel) {
33
+ await command.reply({
34
+ content: 'This command can only be used in a channel',
35
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
36
+ })
37
+ return
38
+ }
39
+
40
+ const isThread = [
41
+ ChannelType.PublicThread,
42
+ ChannelType.PrivateThread,
43
+ ChannelType.AnnouncementThread,
44
+ ].includes(channel.type)
45
+
46
+ if (!isThread) {
47
+ await command.reply({
48
+ content:
49
+ 'This command can only be used in a thread with an active session',
50
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
51
+ })
52
+ return
53
+ }
54
+
55
+ const result = await queueOrSendMessage({
56
+ thread: channel as ThreadChannel,
57
+ prompt: message,
58
+ userId: command.user.id,
59
+ username: command.user.displayName,
60
+ appId,
61
+ })
62
+
63
+ if (result.action === 'no-session') {
64
+ await command.reply({
65
+ content:
66
+ 'No active session in this thread. Send a message directly instead.',
67
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
68
+ })
69
+ return
70
+ }
71
+
72
+ if (result.action === 'no-directory') {
73
+ await command.reply({
74
+ content: 'Could not determine project directory',
75
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
76
+ })
77
+ return
78
+ }
79
+
80
+ if (result.action === 'sent') {
81
+ await command.reply({
82
+ content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
83
+ flags: SILENT_MESSAGE_FLAGS,
84
+ })
85
+ return
86
+ }
87
+
88
+ await command.reply({
89
+ content: `Message queued (position: ${result.position}). Will be sent after current response.`,
90
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
91
+ })
92
+ }
93
+
94
+ export async function handleClearQueueCommand({
95
+ command,
96
+ }: CommandContext): Promise<void> {
97
+ const channel = command.channel
98
+
99
+ if (!channel) {
100
+ await command.reply({
101
+ content: 'This command can only be used in a channel',
102
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
103
+ })
104
+ return
105
+ }
106
+
107
+ const isThread = [
108
+ ChannelType.PublicThread,
109
+ ChannelType.PrivateThread,
110
+ ChannelType.AnnouncementThread,
111
+ ].includes(channel.type)
112
+
113
+ if (!isThread) {
114
+ await command.reply({
115
+ content: 'This command can only be used in a thread',
116
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
117
+ })
118
+ return
119
+ }
120
+
121
+ const queueLength = getQueueLength(channel.id)
122
+
123
+ if (queueLength === 0) {
124
+ await command.reply({
125
+ content: 'No messages in queue',
126
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
127
+ })
128
+ return
129
+ }
130
+
131
+ clearQueue(channel.id)
132
+
133
+ await command.reply({
134
+ content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
135
+ flags: SILENT_MESSAGE_FLAGS,
136
+ })
137
+
138
+ logger.log(
139
+ `[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`,
140
+ )
141
+ }
142
+
143
+ export async function handleQueueCommandCommand({
144
+ command,
145
+ appId,
146
+ }: CommandContext): Promise<void> {
147
+ const commandName = command.options.getString('command', true)
148
+ const args = command.options.getString('arguments') || ''
149
+ const channel = command.channel
150
+
151
+ if (!channel) {
152
+ await command.reply({
153
+ content: 'This command can only be used in a channel',
154
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
155
+ })
156
+ return
157
+ }
158
+
159
+ const isThread = [
160
+ ChannelType.PublicThread,
161
+ ChannelType.PrivateThread,
162
+ ChannelType.AnnouncementThread,
163
+ ].includes(channel.type)
164
+
165
+ if (!isThread) {
166
+ await command.reply({
167
+ content:
168
+ 'This command can only be used in a thread with an active session',
169
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
170
+ })
171
+ return
172
+ }
173
+
174
+ const sessionId = await getThreadSession(channel.id)
175
+
176
+ if (!sessionId) {
177
+ await command.reply({
178
+ content:
179
+ 'No active session in this thread. Send a message directly instead.',
180
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
181
+ })
182
+ return
183
+ }
184
+
185
+ // Validate command exists in registered user commands
186
+ const isKnownCommand = registeredUserCommands.some((cmd) => {
187
+ return cmd.name === commandName
188
+ })
189
+ if (!isKnownCommand) {
190
+ await command.reply({
191
+ content: `Unknown command: /${commandName}. Use autocomplete to pick from available commands.`,
192
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
193
+ })
194
+ return
195
+ }
196
+
197
+ const commandPayload = { name: commandName, arguments: args }
198
+ const displayText = `/${commandName}`
199
+
200
+ // Check if there's an active request running
201
+ const existingController = abortControllers.get(sessionId)
202
+ const hasActiveRequest = Boolean(
203
+ existingController && !existingController.signal.aborted,
204
+ )
205
+ if (existingController && existingController.signal.aborted) {
206
+ abortControllers.delete(sessionId)
207
+ }
208
+
209
+ if (!hasActiveRequest) {
210
+ const resolved = await resolveWorkingDirectory({
211
+ channel: channel as ThreadChannel,
212
+ })
213
+
214
+ if (!resolved) {
215
+ await command.reply({
216
+ content: 'Could not determine project directory',
217
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
218
+ })
219
+ return
220
+ }
221
+
222
+ await command.reply({
223
+ content: `» **${command.user.displayName}:** ${displayText}`,
224
+ flags: SILENT_MESSAGE_FLAGS,
225
+ })
226
+
227
+ logger.log(
228
+ `[QUEUE] No active request, sending command immediately in thread ${channel.id}`,
229
+ )
230
+
231
+ handleOpencodeSession({
232
+ prompt: '',
233
+ thread: channel as ThreadChannel,
234
+ projectDirectory: resolved.projectDirectory,
235
+ channelId: (channel as ThreadChannel).parentId || channel.id,
236
+ command: commandPayload,
237
+ appId,
238
+ }).catch(async (e) => {
239
+ logger.error(`[QUEUE] Failed to send command:`, e)
240
+ void notifyError(e, 'Queue: failed to send command')
241
+ const errorMsg = e instanceof Error ? e.message : String(e)
242
+ await sendThreadMessage(
243
+ channel as ThreadChannel,
244
+ `Failed: ${errorMsg.slice(0, 200)}`,
245
+ )
246
+ })
247
+
248
+ return
249
+ }
250
+
251
+ // Add to queue with command payload
252
+ const queuePosition = addToQueue({
253
+ threadId: channel.id,
254
+ message: {
255
+ prompt: '',
256
+ userId: command.user.id,
257
+ username: command.user.displayName,
258
+ queuedAt: Date.now(),
259
+ appId,
260
+ command: commandPayload,
261
+ },
262
+ })
263
+
264
+ await command.reply({
265
+ content: `Command queued (position: ${queuePosition}): ${displayText}`,
266
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
267
+ })
268
+
269
+ logger.log(
270
+ `[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`,
271
+ )
272
+ }
273
+
274
+ export async function handleQueueCommandAutocomplete({
275
+ interaction,
276
+ }: AutocompleteContext): Promise<void> {
277
+ const focused = interaction.options.getFocused(true)
278
+
279
+ if (focused.name !== 'command') {
280
+ await interaction.respond([])
281
+ return
282
+ }
283
+
284
+ const query = focused.value.toLowerCase()
285
+ const choices = registeredUserCommands
286
+ .filter((cmd) => {
287
+ return cmd.name.toLowerCase().includes(query)
288
+ })
289
+ .slice(0, 25)
290
+ .map((cmd) => ({
291
+ name: `/${cmd.name} - ${cmd.description}`.slice(0, 100),
292
+ value: cmd.name.slice(0, 100),
293
+ }))
294
+
295
+ await interaction.respond(choices)
296
+ }
@@ -0,0 +1,155 @@
1
+ // /remove-project command - Remove Discord channels for a project.
2
+
3
+ import path from 'node:path'
4
+ import * as errore from 'errore'
5
+ import type { CommandContext, AutocompleteContext } from './types.js'
6
+ import {
7
+ findChannelsByDirectory,
8
+ deleteChannelDirectoriesByDirectory,
9
+ getAllTextChannelDirectories,
10
+ } from '../database.js'
11
+ import { createLogger, LogPrefix } from '../logger.js'
12
+ import { abbreviatePath } from '../utils.js'
13
+
14
+ const logger = createLogger(LogPrefix.REMOVE_PROJECT)
15
+
16
+ export async function handleRemoveProjectCommand({
17
+ command,
18
+ appId,
19
+ }: CommandContext): Promise<void> {
20
+ await command.deferReply({ ephemeral: false })
21
+
22
+ const directory = command.options.getString('project', true)
23
+ const guild = command.guild
24
+
25
+ if (!guild) {
26
+ await command.editReply('This command can only be used in a guild')
27
+ return
28
+ }
29
+
30
+ try {
31
+ // Get channel IDs for this directory
32
+ const channels = await findChannelsByDirectory({ directory })
33
+
34
+ if (channels.length === 0) {
35
+ await command.editReply(
36
+ `No channels found for directory: \`${directory}\``,
37
+ )
38
+ return
39
+ }
40
+
41
+ const deletedChannels: string[] = []
42
+ const failedChannels: string[] = []
43
+
44
+ for (const { channel_id, channel_type } of channels as Array<{
45
+ channel_id: string
46
+ channel_type: string
47
+ }>) {
48
+ const channel = await errore.tryAsync({
49
+ try: () => guild.channels.fetch(channel_id),
50
+ catch: (e) => e as Error,
51
+ })
52
+
53
+ if (channel instanceof Error) {
54
+ logger.error(`Failed to fetch channel ${channel_id}:`, channel)
55
+ failedChannels.push(`${channel_type}: ${channel_id}`)
56
+ continue
57
+ }
58
+
59
+ if (channel) {
60
+ try {
61
+ await channel.delete(`Removed by /remove-project command`)
62
+ deletedChannels.push(`${channel_type}: ${channel_id}`)
63
+ } catch (error) {
64
+ logger.error(`Failed to delete channel ${channel_id}:`, error)
65
+ failedChannels.push(`${channel_type}: ${channel_id}`)
66
+ }
67
+ } else {
68
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
69
+ }
70
+ }
71
+
72
+ // Remove from database
73
+ await deleteChannelDirectoriesByDirectory(directory)
74
+
75
+ const projectName = path.basename(directory)
76
+ let message = `Removed project **${projectName}**\n`
77
+ message += `Directory: \`${directory}\`\n\n`
78
+
79
+ if (deletedChannels.length > 0) {
80
+ message += `Deleted channels:\n${deletedChannels.map((c) => `- ${c}`).join('\n')}`
81
+ }
82
+
83
+ if (failedChannels.length > 0) {
84
+ message += `\n\nFailed to delete (may be in another server):\n${failedChannels.map((c) => `- ${c}`).join('\n')}`
85
+ }
86
+
87
+ await command.editReply(message)
88
+ logger.log(`Removed project ${projectName} at ${directory}`)
89
+ } catch (error) {
90
+ logger.error('[REMOVE-PROJECT] Error:', error)
91
+ await command.editReply(
92
+ `Failed to remove project: ${error instanceof Error ? error.message : 'Unknown error'}`,
93
+ )
94
+ }
95
+ }
96
+
97
+ export async function handleRemoveProjectAutocomplete({
98
+ interaction,
99
+ appId,
100
+ }: AutocompleteContext): Promise<void> {
101
+ const focusedValue = interaction.options.getFocused()
102
+ const guild = interaction.guild
103
+
104
+ if (!guild) {
105
+ await interaction.respond([])
106
+ return
107
+ }
108
+
109
+ try {
110
+ // Get all directories with channels
111
+ const allChannels = (await findChannelsByDirectory({
112
+ channelType: 'text',
113
+ })) as Array<{
114
+ directory: string
115
+ channel_id: string
116
+ }>
117
+
118
+ // Filter to only channels that exist in this guild
119
+ const projectsInGuild: { directory: string; channelId: string }[] = []
120
+
121
+ for (const { directory, channel_id } of allChannels) {
122
+ const channel = await errore.tryAsync({
123
+ try: () => guild.channels.fetch(channel_id),
124
+ catch: (e) => e as Error,
125
+ })
126
+ if (channel instanceof Error) {
127
+ // Channel not in this guild, skip
128
+ continue
129
+ }
130
+ if (channel) {
131
+ projectsInGuild.push({ directory, channelId: channel_id })
132
+ }
133
+ }
134
+
135
+ const projects = projectsInGuild
136
+ .filter(({ directory }) => {
137
+ const baseName = path.basename(directory)
138
+ const searchText = `${baseName} ${directory}`.toLowerCase()
139
+ return searchText.includes(focusedValue.toLowerCase())
140
+ })
141
+ .slice(0, 25)
142
+ .map(({ directory }) => {
143
+ const name = `${path.basename(directory)} (${abbreviatePath(directory)})`
144
+ return {
145
+ name: name.length > 100 ? name.slice(0, 99) + '...' : name,
146
+ value: directory,
147
+ }
148
+ })
149
+
150
+ await interaction.respond(projects)
151
+ } catch (error) {
152
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
153
+ await interaction.respond([])
154
+ }
155
+ }