kimaki 0.4.76 → 0.4.78

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 (162) hide show
  1. package/dist/adapter-rest-boundary.test.js +34 -0
  2. package/dist/agent-model.e2e.test.js +2 -20
  3. package/dist/cli.js +50 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/diff.js +20 -85
  6. package/dist/commands/merge-worktree.js +5 -17
  7. package/dist/commands/new-worktree.js +5 -9
  8. package/dist/commands/permissions.js +77 -11
  9. package/dist/commands/resume.js +5 -9
  10. package/dist/commands/screenshare.js +295 -0
  11. package/dist/commands/session.js +6 -17
  12. package/dist/critique-utils.js +95 -0
  13. package/dist/diff-patch-plugin.js +314 -0
  14. package/dist/discord-bot.js +19 -14
  15. package/dist/discord-js-import-boundary.test.js +62 -0
  16. package/dist/discord-utils.js +44 -0
  17. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  18. package/dist/gateway-proxy.e2e.test.js +2 -5
  19. package/dist/generated/cloudflare/browser.js +17 -0
  20. package/dist/generated/cloudflare/client.js +34 -0
  21. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  22. package/dist/generated/cloudflare/enums.js +48 -0
  23. package/dist/generated/cloudflare/internal/class.js +47 -0
  24. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  25. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  26. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  27. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  28. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  33. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  34. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  35. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  36. package/dist/generated/cloudflare/models/global_models.js +1 -0
  37. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  38. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  39. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  40. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  41. package/dist/generated/cloudflare/models/session_events.js +1 -0
  42. package/dist/generated/cloudflare/models/session_models.js +1 -0
  43. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  44. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  45. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  46. package/dist/generated/cloudflare/models.js +1 -0
  47. package/dist/generated/node/browser.js +17 -0
  48. package/dist/generated/node/client.js +37 -0
  49. package/dist/generated/node/commonInputTypes.js +10 -0
  50. package/dist/generated/node/enums.js +48 -0
  51. package/dist/generated/node/internal/class.js +49 -0
  52. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  53. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  54. package/dist/generated/node/models/bot_api_keys.js +1 -0
  55. package/dist/generated/node/models/bot_tokens.js +1 -0
  56. package/dist/generated/node/models/channel_agents.js +1 -0
  57. package/dist/generated/node/models/channel_directories.js +1 -0
  58. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  59. package/dist/generated/node/models/channel_models.js +1 -0
  60. package/dist/generated/node/models/channel_verbosity.js +1 -0
  61. package/dist/generated/node/models/channel_worktrees.js +1 -0
  62. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  63. package/dist/generated/node/models/global_models.js +1 -0
  64. package/dist/generated/node/models/ipc_requests.js +1 -0
  65. package/dist/generated/node/models/part_messages.js +1 -0
  66. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  67. package/dist/generated/node/models/session_agents.js +1 -0
  68. package/dist/generated/node/models/session_events.js +1 -0
  69. package/dist/generated/node/models/session_models.js +1 -0
  70. package/dist/generated/node/models/session_start_sources.js +1 -0
  71. package/dist/generated/node/models/thread_sessions.js +1 -0
  72. package/dist/generated/node/models/thread_worktrees.js +1 -0
  73. package/dist/generated/node/models.js +1 -0
  74. package/dist/interaction-handler.js +10 -0
  75. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  76. package/dist/message-flags-boundary.test.js +54 -0
  77. package/dist/message-formatting.js +3 -62
  78. package/dist/onboarding-tutorial-plugin.js +1 -1
  79. package/dist/opencode-command.js +129 -0
  80. package/dist/opencode-command.test.js +48 -0
  81. package/dist/opencode-interrupt-plugin.js +19 -1
  82. package/dist/opencode-interrupt-plugin.test.js +0 -5
  83. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  84. package/dist/opencode-plugin.js +4 -4
  85. package/dist/opencode.js +150 -27
  86. package/dist/patch-text-parser.js +97 -0
  87. package/dist/platform/components-v2.js +20 -0
  88. package/dist/platform/discord-adapter.js +1440 -0
  89. package/dist/platform/discord-routes.js +31 -0
  90. package/dist/platform/message-flags.js +8 -0
  91. package/dist/platform/platform-value.js +41 -0
  92. package/dist/platform/slack-adapter.js +872 -0
  93. package/dist/platform/slack-markdown.js +169 -0
  94. package/dist/platform/types.js +4 -0
  95. package/dist/queue-advanced-e2e-setup.js +265 -0
  96. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  97. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  98. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  99. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  100. package/dist/session-handler/event-stream-state.js +5 -0
  101. package/dist/session-handler/event-stream-state.test.js +6 -2
  102. package/dist/session-handler/thread-session-runtime.js +32 -2
  103. package/dist/system-message.js +26 -23
  104. package/dist/test-utils.js +16 -0
  105. package/dist/thread-message-queue.e2e.test.js +2 -20
  106. package/dist/utils.js +3 -1
  107. package/dist/voice-message.e2e.test.js +2 -20
  108. package/dist/voice.js +122 -9
  109. package/dist/voice.test.js +17 -2
  110. package/dist/websockify.js +69 -0
  111. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  112. package/package.json +4 -2
  113. package/skills/critique/SKILL.md +17 -0
  114. package/skills/egaki/SKILL.md +35 -0
  115. package/skills/event-sourcing-state/SKILL.md +252 -0
  116. package/skills/goke/SKILL.md +1 -0
  117. package/skills/npm-package/SKILL.md +21 -2
  118. package/skills/playwriter/SKILL.md +1 -1
  119. package/skills/x-articles/SKILL.md +554 -0
  120. package/src/agent-model.e2e.test.ts +4 -19
  121. package/src/cli.ts +60 -13
  122. package/src/commands/diff.ts +25 -99
  123. package/src/commands/merge-worktree.ts +5 -21
  124. package/src/commands/new-worktree.ts +5 -11
  125. package/src/commands/permissions.ts +100 -15
  126. package/src/commands/resume.ts +5 -12
  127. package/src/commands/screenshare.ts +354 -0
  128. package/src/commands/session.ts +6 -23
  129. package/src/critique-utils.ts +139 -0
  130. package/src/discord-bot.ts +20 -15
  131. package/src/discord-utils.ts +53 -0
  132. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  133. package/src/gateway-proxy.e2e.test.ts +2 -5
  134. package/src/interaction-handler.ts +15 -0
  135. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  136. package/src/message-formatting.ts +3 -68
  137. package/src/onboarding-tutorial-plugin.ts +1 -1
  138. package/src/opencode-command.test.ts +70 -0
  139. package/src/opencode-command.ts +188 -0
  140. package/src/opencode-interrupt-plugin.test.ts +0 -5
  141. package/src/opencode-interrupt-plugin.ts +34 -1
  142. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  143. package/src/opencode-plugin.ts +5 -4
  144. package/src/opencode.ts +199 -32
  145. package/src/patch-text-parser.ts +107 -0
  146. package/src/queue-advanced-e2e-setup.ts +273 -0
  147. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  148. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  149. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  150. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  151. package/src/session-handler/event-stream-state.test.ts +6 -2
  152. package/src/session-handler/event-stream-state.ts +5 -0
  153. package/src/session-handler/thread-session-runtime.ts +45 -2
  154. package/src/system-message.ts +26 -23
  155. package/src/test-utils.ts +17 -0
  156. package/src/thread-message-queue.e2e.test.ts +2 -20
  157. package/src/utils.ts +3 -1
  158. package/src/voice-message.e2e.test.ts +3 -20
  159. package/src/voice.test.ts +26 -2
  160. package/src/voice.ts +147 -9
  161. package/src/websockify.ts +101 -0
  162. package/src/worktree-lifecycle.e2e.test.ts +391 -0
@@ -0,0 +1,354 @@
1
+ // /screenshare command - Start screen sharing via VNC + WebSocket bridge + kimaki tunnel.
2
+ // On macOS: uses built-in Screen Sharing (port 5900).
3
+ // On Linux: spawns x11vnc against the current $DISPLAY.
4
+ // Exposes the VNC stream via an in-process websockify bridge and a traforo tunnel,
5
+ // then sends the user a noVNC URL they can open in a browser.
6
+ //
7
+ // /screenshare-stop command - Stops the active screen share for this guild.
8
+
9
+ import { MessageFlags } from 'discord.js'
10
+ import crypto from 'node:crypto'
11
+ import { spawn, type ChildProcess } from 'node:child_process'
12
+ import net from 'node:net'
13
+ import { TunnelClient } from 'traforo/client'
14
+ import type { CommandContext } from './types.js'
15
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
16
+ import { startWebsockify } from '../websockify.js'
17
+ import { createLogger } from '../logger.js'
18
+ import { execAsync } from '../worktrees.js'
19
+ import type { WebSocketServer } from 'ws'
20
+
21
+ const logger = createLogger('SCREEN')
22
+
23
+ export type ScreenshareSession = {
24
+ tunnelClient: TunnelClient
25
+ wss: WebSocketServer
26
+ /** x11vnc child process, only on Linux */
27
+ vncProcess: ChildProcess | undefined
28
+ url: string
29
+ noVncUrl: string
30
+ startedBy: string
31
+ startedAt: number
32
+ /** Auto-kill timer */
33
+ timeoutTimer: ReturnType<typeof setTimeout>
34
+ }
35
+
36
+ /** One active screenshare per guild (Discord) or per machine (CLI) */
37
+ const activeSessions = new Map<string, ScreenshareSession>()
38
+
39
+ const VNC_PORT = 5900
40
+ const MAX_SESSION_MS = 60 * 60 * 1000 // 1 hour
41
+ const TUNNEL_BASE_DOMAIN = 'kimaki.xyz'
42
+
43
+ // Public noVNC client — we point it at our tunnel URL
44
+ export function buildNoVncUrl({ tunnelHost }: { tunnelHost: string }): string {
45
+ const params = new URLSearchParams({
46
+ autoconnect: 'true',
47
+ host: tunnelHost,
48
+ port: '443',
49
+ encrypt: '1',
50
+ resize: 'scale',
51
+ view_only: 'false',
52
+ })
53
+ return `https://novnc.com/noVNC/vnc.html?${params.toString()}`
54
+ }
55
+
56
+ // macOS has two separate services:
57
+ // - "Screen Sharing" = view-only VNC (com.apple.screensharing)
58
+ // - "Remote Management" = full control VNC with mouse/keyboard (ARDAgent)
59
+ // We need Remote Management for interactive control, not just Screen Sharing.
60
+ export async function ensureMacRemoteManagement(): Promise<void> {
61
+ // Check if port 5900 is listening via netstat (no sudo needed).
62
+ // lsof and launchctl list both require sudo for system daemons.
63
+ try {
64
+ const { stdout } = await execAsync(
65
+ 'netstat -an | grep "\\.5900 " | grep LISTEN',
66
+ { timeout: 5000 },
67
+ )
68
+ if (stdout.trim()) {
69
+ return
70
+ }
71
+ } catch {
72
+ // not listening
73
+ }
74
+
75
+ throw new Error(
76
+ 'macOS Remote Management is not enabled.\n' +
77
+ 'Enable it: **System Settings > General > Sharing > Remote Management**\n' +
78
+ 'Make sure "VNC viewers may control screen with password" is enabled.\n' +
79
+ 'Or via terminal:\n' +
80
+ '```\nsudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \\\n' +
81
+ ' -activate -configure -allowAccessFor -allUsers -privs -all \\\n' +
82
+ ' -clientopts -setvnclegacy -vnclegacy yes \\\n' +
83
+ ' -restart -agent -console\n```',
84
+ )
85
+ }
86
+
87
+ export function spawnX11Vnc(): ChildProcess {
88
+ const display = process.env['DISPLAY'] || ':0'
89
+ const child = spawn('x11vnc', [
90
+ '-display', display,
91
+ '-nopw',
92
+ '-localhost',
93
+ '-rfbport', String(VNC_PORT),
94
+ '-shared',
95
+ '-forever',
96
+ ], {
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ })
99
+
100
+ child.stdout?.on('data', (data: Buffer) => {
101
+ logger.log(`x11vnc: ${data.toString().trim()}`)
102
+ })
103
+ child.stderr?.on('data', (data: Buffer) => {
104
+ logger.error(`x11vnc: ${data.toString().trim()}`)
105
+ })
106
+
107
+ return child
108
+ }
109
+
110
+ function waitForPort({
111
+ port,
112
+ process: proc,
113
+ timeoutMs,
114
+ }: {
115
+ port: number
116
+ process: ChildProcess
117
+ timeoutMs: number
118
+ }): Promise<void> {
119
+ return new Promise((resolve, reject) => {
120
+ const maxAttempts = Math.ceil(timeoutMs / 100)
121
+ let attempts = 0
122
+ const check = () => {
123
+ if (proc.exitCode !== null) {
124
+ reject(new Error(`x11vnc exited with code ${proc.exitCode} before becoming ready`))
125
+ return
126
+ }
127
+ const sock = net.createConnection(port, 'localhost')
128
+ sock.on('connect', () => {
129
+ sock.destroy()
130
+ resolve()
131
+ })
132
+ sock.on('error', () => {
133
+ sock.destroy()
134
+ if (++attempts >= maxAttempts) {
135
+ reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
136
+ } else {
137
+ setTimeout(check, 100)
138
+ }
139
+ })
140
+ }
141
+ check()
142
+ })
143
+ }
144
+
145
+ export function cleanupSession(session: ScreenshareSession): void {
146
+ clearTimeout(session.timeoutTimer)
147
+ try {
148
+ session.tunnelClient.close()
149
+ } catch {}
150
+ try {
151
+ session.wss.close()
152
+ } catch {}
153
+ if (session.vncProcess) {
154
+ try {
155
+ session.vncProcess.kill()
156
+ } catch {}
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Core screenshare start logic, reused by both Discord command and CLI.
162
+ * Returns the session or throws on failure.
163
+ */
164
+ export async function startScreenshare({
165
+ sessionKey,
166
+ startedBy,
167
+ }: {
168
+ sessionKey: string
169
+ startedBy: string
170
+ }): Promise<ScreenshareSession> {
171
+ const existing = activeSessions.get(sessionKey)
172
+ if (existing) {
173
+ throw new Error(`Screen sharing is already active: ${existing.noVncUrl}`)
174
+ }
175
+
176
+ const platform = process.platform
177
+ let vncProcess: ChildProcess | undefined
178
+
179
+ // Step 1: ensure VNC server is running
180
+ if (platform === 'darwin') {
181
+ await ensureMacRemoteManagement()
182
+ } else if (platform === 'linux') {
183
+ if (!process.env['DISPLAY']) {
184
+ throw new Error('No $DISPLAY found. Screen sharing requires a running X11 display.')
185
+ }
186
+ try {
187
+ await execAsync('which x11vnc', { timeout: 3000 })
188
+ } catch {
189
+ throw new Error('x11vnc is not installed. Install it with: sudo apt install x11vnc')
190
+ }
191
+ vncProcess = spawnX11Vnc()
192
+ // Wait for x11vnc to actually be ready (port 5900 accepting connections)
193
+ // instead of a blind 1s sleep. Polls every 100ms, fails if process exits first.
194
+ await waitForPort({ port: VNC_PORT, process: vncProcess, timeoutMs: 3000 })
195
+ } else {
196
+ throw new Error(`Screen sharing is not supported on ${platform}. Only macOS and Linux are supported.`)
197
+ }
198
+
199
+ // Step 2: start in-process websockify bridge
200
+ let wsInstance: Awaited<ReturnType<typeof startWebsockify>>
201
+ try {
202
+ wsInstance = await startWebsockify({
203
+ wsPort: 0,
204
+ tcpHost: 'localhost',
205
+ tcpPort: VNC_PORT,
206
+ })
207
+ } catch (err) {
208
+ if (vncProcess) {
209
+ vncProcess.kill()
210
+ }
211
+ throw err
212
+ }
213
+
214
+ // Step 3: create tunnel
215
+ const tunnelId = crypto.randomBytes(8).toString('hex')
216
+ const tunnelClient = new TunnelClient({
217
+ localPort: wsInstance.port,
218
+ tunnelId,
219
+ baseDomain: TUNNEL_BASE_DOMAIN,
220
+ })
221
+
222
+ try {
223
+ await Promise.race([
224
+ tunnelClient.connect(),
225
+ new Promise<never>((_, reject) => {
226
+ setTimeout(() => {
227
+ reject(new Error('Tunnel connection timed out after 15s'))
228
+ }, 15000)
229
+ }),
230
+ ])
231
+ } catch (err) {
232
+ tunnelClient.close()
233
+ wsInstance.close()
234
+ if (vncProcess) {
235
+ vncProcess.kill()
236
+ }
237
+ throw err
238
+ }
239
+
240
+ const tunnelHost = `${tunnelId}-tunnel.${TUNNEL_BASE_DOMAIN}`
241
+ const tunnelUrl = `https://${tunnelHost}`
242
+ const noVncUrl = buildNoVncUrl({ tunnelHost })
243
+
244
+ // Auto-kill after 1 hour
245
+ const timeoutTimer = setTimeout(() => {
246
+ logger.log(`Screen share auto-stopped after 1 hour (key: ${sessionKey})`)
247
+ stopScreenshare({ sessionKey })
248
+ }, MAX_SESSION_MS)
249
+ // Don't keep the process alive just for this timer
250
+ timeoutTimer.unref()
251
+
252
+ const session: ScreenshareSession = {
253
+ tunnelClient,
254
+ wss: wsInstance.wss,
255
+ vncProcess,
256
+ url: tunnelUrl,
257
+ noVncUrl,
258
+ startedBy,
259
+ startedAt: Date.now(),
260
+ timeoutTimer,
261
+ }
262
+
263
+ activeSessions.set(sessionKey, session)
264
+ logger.log(`Screen share started by ${startedBy}: ${tunnelUrl}`)
265
+
266
+ return session
267
+ }
268
+
269
+ /**
270
+ * Core screenshare stop logic, reused by both Discord command and CLI.
271
+ */
272
+ export function stopScreenshare({ sessionKey }: { sessionKey: string }): boolean {
273
+ const session = activeSessions.get(sessionKey)
274
+ if (!session) {
275
+ return false
276
+ }
277
+ cleanupSession(session)
278
+ activeSessions.delete(sessionKey)
279
+ logger.log(`Screen share stopped (key: ${sessionKey})`)
280
+ return true
281
+ }
282
+
283
+ export async function handleScreenshareCommand({
284
+ command,
285
+ }: CommandContext): Promise<void> {
286
+ const guildId = command.guildId
287
+ if (!guildId) {
288
+ await command.reply({
289
+ content: 'This command can only be used in a server',
290
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
291
+ })
292
+ return
293
+ }
294
+
295
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
296
+
297
+ try {
298
+ const session = await startScreenshare({
299
+ sessionKey: guildId,
300
+ startedBy: command.user.tag,
301
+ })
302
+ await command.editReply({
303
+ content: `Screen sharing started\n${session.noVncUrl}`,
304
+ })
305
+ } catch (err) {
306
+ logger.error('Failed to start screen share:', err)
307
+ await command.editReply({
308
+ content: `Failed to start screen share: ${err instanceof Error ? err.message : String(err)}`,
309
+ })
310
+ }
311
+ }
312
+
313
+ export async function handleScreenshareStopCommand({
314
+ command,
315
+ }: CommandContext): Promise<void> {
316
+ const guildId = command.guildId
317
+ if (!guildId) {
318
+ await command.reply({
319
+ content: 'This command can only be used in a server',
320
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
321
+ })
322
+ return
323
+ }
324
+
325
+ const stopped = stopScreenshare({ sessionKey: guildId })
326
+ if (!stopped) {
327
+ await command.reply({
328
+ content: 'No active screen share to stop',
329
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
330
+ })
331
+ return
332
+ }
333
+
334
+ await command.reply({
335
+ content: 'Screen sharing stopped',
336
+ flags: SILENT_MESSAGE_FLAGS,
337
+ })
338
+ }
339
+
340
+ /** Cleanup all sessions on bot shutdown */
341
+ export function cleanupAllScreenshares(): void {
342
+ for (const [guildId, session] of activeSessions) {
343
+ cleanupSession(session)
344
+ activeSessions.delete(guildId)
345
+ }
346
+ }
347
+
348
+ // Kill all screenshares when the process exits (Ctrl+C, SIGTERM, etc.)
349
+ function onProcessExit(): void {
350
+ cleanupAllScreenshares()
351
+ }
352
+ process.on('SIGINT', onProcessExit)
353
+ process.on('SIGTERM', onProcessExit)
354
+ process.on('exit', onProcessExit)
@@ -6,7 +6,7 @@ import path from 'node:path'
6
6
  import type { CommandContext, AutocompleteContext } from './types.js'
7
7
  import { getChannelDirectory } from '../database.js'
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js'
9
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
9
+ import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js'
10
10
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
11
11
  import { createLogger, LogPrefix } from '../logger.js'
12
12
  import * as errore from 'errore'
@@ -110,17 +110,10 @@ async function handleAgentAutocomplete({
110
110
  }): Promise<void> {
111
111
  const focusedValue = interaction.options.getFocused()
112
112
 
113
- let projectDirectory: string | undefined
114
-
115
- if (
116
- interaction.channel &&
117
- interaction.channel.type === ChannelType.GuildText
118
- ) {
119
- const channelConfig = await getChannelDirectory(interaction.channel.id)
120
- if (channelConfig) {
121
- projectDirectory = channelConfig.directory
122
- }
123
- }
113
+ // interaction.channel can be null when the channel isn't cached
114
+ // (common with gateway-proxy). Use channelId which is always available
115
+ // from the raw interaction payload.
116
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
124
117
 
125
118
  if (!projectDirectory) {
126
119
  await interaction.respond([])
@@ -186,17 +179,7 @@ export async function handleSessionAutocomplete({
186
179
  .filter((f) => f)
187
180
  const currentQuery = (parts[parts.length - 1] || '').trim()
188
181
 
189
- let projectDirectory: string | undefined
190
-
191
- if (
192
- interaction.channel &&
193
- interaction.channel.type === ChannelType.GuildText
194
- ) {
195
- const channelConfig = await getChannelDirectory(interaction.channel.id)
196
- if (channelConfig) {
197
- projectDirectory = channelConfig.directory
198
- }
199
- }
182
+ const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction)
200
183
 
201
184
  if (!projectDirectory) {
202
185
  await interaction.respond([])
@@ -0,0 +1,139 @@
1
+ // Shared utilities for invoking the critique CLI and parsing its JSON output.
2
+ // Used by /diff command and footer diff link uploads.
3
+
4
+ import { execAsync } from './worktrees.js'
5
+ import { createLogger, LogPrefix } from './logger.js'
6
+
7
+ const logger = createLogger(LogPrefix.DIFF)
8
+
9
+ const CRITIQUE_TIMEOUT_MS = 30_000
10
+
11
+ /**
12
+ * Shell-quote a string by wrapping in single quotes and escaping embedded
13
+ * single quotes. Prevents injection when interpolating into shell commands.
14
+ */
15
+ function shellQuote(s: string): string {
16
+ return `'${s.replace(/'/g, "'\\''")}'`
17
+ }
18
+
19
+ export type CritiqueResult = {
20
+ url: string
21
+ id: string
22
+ error?: undefined
23
+ } | {
24
+ url?: undefined
25
+ id?: undefined
26
+ error: string
27
+ }
28
+
29
+ /**
30
+ * Parse critique --json output. Critique prints progress to stderr and JSON
31
+ * to stdout. The JSON line contains { url, id } on success or { error } on
32
+ * failure. We scan all lines for the first valid JSON object with a url or
33
+ * error field, falling back to searching for a critique.work URL in the raw
34
+ * output.
35
+ */
36
+ export function parseCritiqueOutput(output: string): CritiqueResult | undefined {
37
+ const lines = output.trim().split('\n')
38
+ for (const line of lines) {
39
+ if (!line.startsWith('{')) {
40
+ continue
41
+ }
42
+ try {
43
+ const parsed = JSON.parse(line) as {
44
+ url?: string
45
+ id?: string
46
+ error?: string
47
+ }
48
+ if (parsed.error) {
49
+ return { error: parsed.error }
50
+ }
51
+ if (parsed.url && parsed.id) {
52
+ return { url: parsed.url, id: parsed.id }
53
+ }
54
+ } catch {
55
+ // not valid JSON, try next line
56
+ }
57
+ }
58
+ // Fallback: try to find a URL in the raw output
59
+ const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/)
60
+ if (urlMatch) {
61
+ const url = urlMatch[0]
62
+ // Extract ID from URL path: /v/{id}
63
+ const idMatch = url.match(/\/v\/([a-f0-9]+)/)
64
+ const id = idMatch?.[1]
65
+ if (id) {
66
+ return { url, id }
67
+ }
68
+ // URL without parseable id — return as error so callers don't build
69
+ // broken OG image URLs from an empty id
70
+ return { error: url }
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /**
76
+ * Run critique on the current git working tree diff and return the result.
77
+ * Used by the /diff slash command.
78
+ */
79
+ export async function uploadGitDiffViaCritique({
80
+ title,
81
+ cwd,
82
+ }: {
83
+ title: string
84
+ cwd: string
85
+ }): Promise<CritiqueResult | undefined> {
86
+ try {
87
+ const { stdout, stderr } = await execAsync(
88
+ `critique --web ${shellQuote(title)} --json`,
89
+ { cwd, timeout: CRITIQUE_TIMEOUT_MS },
90
+ )
91
+ return parseCritiqueOutput(stdout || stderr)
92
+ } catch (error) {
93
+ // exec error includes stdout/stderr — try to parse JSON from it
94
+ const execError = error as {
95
+ stdout?: string
96
+ stderr?: string
97
+ message?: string
98
+ }
99
+ const output = execError.stdout || execError.stderr || ''
100
+ const parsed = parseCritiqueOutput(output)
101
+ if (parsed) {
102
+ return parsed
103
+ }
104
+ const message = execError.message || 'Unknown error'
105
+ if (message.includes('command not found') || message.includes('ENOENT')) {
106
+ return { error: 'critique not available' }
107
+ }
108
+ return { error: `Failed to generate diff: ${message.slice(0, 200)}` }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Upload a .patch file to critique.work via critique --stdin.
114
+ * Returns the critique URL on success, undefined on failure.
115
+ * Default timeout is 10s since this runs in the background (footer edit).
116
+ */
117
+ export async function uploadPatchViaCritique({
118
+ patchPath,
119
+ title,
120
+ cwd,
121
+ timeoutMs = 10_000,
122
+ }: {
123
+ patchPath: string
124
+ title: string
125
+ cwd: string
126
+ timeoutMs?: number
127
+ }): Promise<string | undefined> {
128
+ try {
129
+ const { stdout } = await execAsync(
130
+ `critique --stdin --web ${shellQuote(title)} --json < ${shellQuote(patchPath)}`,
131
+ { cwd, timeout: timeoutMs },
132
+ )
133
+ const result = parseCritiqueOutput(stdout)
134
+ return result?.url
135
+ } catch (error) {
136
+ logger.error('critique upload failed:', error)
137
+ return undefined
138
+ }
139
+ }
@@ -47,6 +47,7 @@ import {
47
47
  import { cancelPendingActionButtons } from './commands/action-buttons.js'
48
48
  import { cancelPendingQuestion, type CancelQuestionResult } from './commands/ask-question.js'
49
49
  import { cancelPendingFileUpload } from './commands/file-upload.js'
50
+ import { cancelPendingPermission } from './commands/permissions.js'
50
51
  import { cancelHtmlActionsForThread } from './html-actions.js'
51
52
  import {
52
53
  ensureKimakiCategory,
@@ -495,20 +496,6 @@ export async function startDiscordBot({
495
496
  const thread = channel as ThreadChannel
496
497
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
497
498
 
498
- // Cancel interactive UI when a real user sends a message.
499
- // If a question was pending and answered with the user's text,
500
- // early-return: the message was consumed as the question answer
501
- // and must NOT also be sent as a new prompt (causes abort loops).
502
- if (!message.author.bot && !isCliInjectedPrompt) {
503
- cancelPendingActionButtons(thread.id)
504
- cancelHtmlActionsForThread(thread.id)
505
- const questionResult = await cancelPendingQuestion(thread.id, message.content)
506
- void cancelPendingFileUpload(thread.id)
507
- if (questionResult === 'replied') {
508
- return
509
- }
510
- }
511
-
512
499
  const parent = thread.parent as TextChannel | null
513
500
  let projectDirectory: string | undefined
514
501
  if (parent) {
@@ -603,6 +590,24 @@ export async function startDiscordBot({
603
590
  appId: currentAppId,
604
591
  })
605
592
 
593
+ // Cancel interactive UI when a real user sends a message.
594
+ // If a question was pending and answered with the user's text,
595
+ // early-return: the message was consumed as the question answer
596
+ // and must NOT also be sent as a new prompt (causes abort loops).
597
+ if (!message.author.bot && !isCliInjectedPrompt) {
598
+ cancelPendingActionButtons(thread.id)
599
+ cancelHtmlActionsForThread(thread.id)
600
+ const dismissedPermission = await cancelPendingPermission(thread.id)
601
+ if (dismissedPermission) {
602
+ runtime.abortActiveRun('user sent a new message while permission was pending')
603
+ }
604
+ const questionResult = await cancelPendingQuestion(thread.id, message.content)
605
+ void cancelPendingFileUpload(thread.id)
606
+ if (questionResult === 'replied') {
607
+ return
608
+ }
609
+ }
610
+
606
611
  // Expensive pre-processing (voice transcription, context fetch,
607
612
  // attachment download) runs inside the runtime's serialized
608
613
  // preprocess chain, preserving Discord arrival order without
@@ -808,7 +813,7 @@ export async function startDiscordBot({
808
813
  },
809
814
  })
810
815
  } else {
811
- discordLogger.log(`Channel type ${channel.type} is not supported`)
816
+ // discordLogger.log(`Channel type ${channel.type} is not supported`)
812
817
  }
813
818
  } catch (error) {
814
819
  voiceLogger.error('Discord handler error:', error)
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  type APIInteractionGuildMember,
7
+ type AutocompleteInteraction,
7
8
  ChannelType,
8
9
  GuildMember,
9
10
  MessageFlags,
@@ -645,6 +646,58 @@ export async function getKimakiMetadata(
645
646
  }
646
647
  }
647
648
 
649
+ /**
650
+ * Resolve project directory from an autocomplete interaction.
651
+ * Uses interaction.channelId (always available from raw payload) instead of
652
+ * interaction.channel (cache-based getter, often null with gateway-proxy).
653
+ * Checks the channel ID directly in DB, then tries thread worktree lookup,
654
+ * then falls back to fetching the channel to resolve thread parent.
655
+ */
656
+ export async function resolveProjectDirectoryFromAutocomplete(
657
+ interaction: Pick<AutocompleteInteraction, 'channelId' | 'channel' | 'client'>,
658
+ ): Promise<string | undefined> {
659
+ const channelId = interaction.channelId
660
+
661
+ // Direct channel lookup — works when the command is run from a project text channel
662
+ const channelConfig = await getChannelDirectory(channelId)
663
+ if (channelConfig) {
664
+ return channelConfig.directory
665
+ }
666
+
667
+ // If we're in a thread, try worktree info first (has project_directory)
668
+ const worktreeInfo = await getThreadWorktree(channelId)
669
+ if (worktreeInfo?.project_directory) {
670
+ return worktreeInfo.project_directory
671
+ }
672
+
673
+ // Thread fallback: resolve parent channel ID and look up its directory.
674
+ // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
675
+ const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null
676
+ if (cachedParentId) {
677
+ const parentConfig = await getChannelDirectory(cachedParentId)
678
+ if (parentConfig) {
679
+ return parentConfig.directory
680
+ }
681
+ }
682
+
683
+ // Last resort: fetch the channel from Discord API to get parentId for threads
684
+ // when the channel isn't cached at all (common with gateway-proxy).
685
+ if (!cachedParentId) {
686
+ const fetched = await errore.tryAsync({
687
+ try: () => { return interaction.client.channels.fetch(channelId) },
688
+ catch: (e) => { return e as Error },
689
+ })
690
+ if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
691
+ const parentConfig = await getChannelDirectory(fetched.parentId)
692
+ if (parentConfig) {
693
+ return parentConfig.directory
694
+ }
695
+ }
696
+ }
697
+
698
+ return undefined
699
+ }
700
+
648
701
  /**
649
702
  * Resolve the working directory for a channel or thread.
650
703
  * Returns both the base project directory (for server init) and the working directory