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,314 @@
1
+ // Tracks tool-driven file changes and writes per-message/session .patch files
2
+ // to $KIMAKI_DATA_DIR/diffs/ (default ~/.kimaki/diffs/).
3
+ //
4
+ // The bot process reads these patch files in emitFooter() to upload them via
5
+ // critique and append [ses diff] / [msg diff] links to the footer message.
6
+ //
7
+ // Lifecycle:
8
+ // tool.execute.before → snapshot file contents before edit
9
+ // tool.execute.after → snapshot again, record the delta
10
+ // message.updated (completed) → write {sessionId}-{messageId}.patch
11
+ // session.idle → write {sessionId}.patch (cumulative, overwrites)
12
+ // session.deleted → cleanup in-memory state
13
+ //
14
+ // Pruning: caps the diffs folder at MAX_PATCH_FILES, deleting oldest by mtime.
15
+ import { createTwoFilesPatch } from 'diff';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { extractPatchFilePaths } from './patch-text-parser.js';
19
+ const TOOLS = new Set(['edit', 'write', 'apply_patch', 'multiedit']);
20
+ // Skip files larger than 512KB to avoid memory spikes
21
+ const MAX_FILE_BYTES = 512 * 1024;
22
+ // Max patch files before pruning oldest
23
+ const MAX_PATCH_FILES = 200;
24
+ // Pending tool calls older than 10 min are garbage collected
25
+ const PENDING_TTL_MS = 10 * 60 * 1000;
26
+ function extractPaths(tool, args) {
27
+ if (tool === 'edit' || tool === 'write' || tool === 'multiedit') {
28
+ return typeof args.filePath === 'string' ? [args.filePath] : [];
29
+ }
30
+ if (tool === 'apply_patch') {
31
+ const text = (args.patchText ?? args.patch ?? '');
32
+ return extractPatchFilePaths(text);
33
+ }
34
+ return [];
35
+ }
36
+ function buildPatch(files) {
37
+ const parts = [];
38
+ for (const [file, { first, last }] of files) {
39
+ if (first === last) {
40
+ continue;
41
+ }
42
+ parts.push(createTwoFilesPatch(file, file, first, last));
43
+ }
44
+ return parts.join('\n');
45
+ }
46
+ async function readSafe(file) {
47
+ const stat = await fs.promises.stat(file).catch(() => {
48
+ return undefined;
49
+ });
50
+ if (!stat) {
51
+ return '';
52
+ }
53
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
54
+ return undefined;
55
+ }
56
+ return fs.promises.readFile(file, 'utf8').catch(() => {
57
+ return '';
58
+ });
59
+ }
60
+ async function prunePatchDir(dir) {
61
+ const entries = await fs.promises.readdir(dir).catch(() => {
62
+ return [];
63
+ });
64
+ if (entries.length <= MAX_PATCH_FILES) {
65
+ return;
66
+ }
67
+ const files = (await Promise.all(entries.map(async (name) => {
68
+ const full = path.join(dir, name);
69
+ const stat = await fs.promises.stat(full).catch(() => {
70
+ return undefined;
71
+ });
72
+ return stat?.isFile() ? { full, mtime: stat.mtimeMs } : undefined;
73
+ }))).filter((f) => {
74
+ return f !== undefined;
75
+ });
76
+ files.sort((a, b) => {
77
+ return b.mtime - a.mtime;
78
+ });
79
+ await Promise.all(files.slice(MAX_PATCH_FILES).map((f) => {
80
+ return fs.promises.unlink(f.full).catch(() => { });
81
+ }));
82
+ }
83
+ // Atomic write: write to a temp file then rename to avoid torn reads
84
+ // from the bot process reading a partially-written patch.
85
+ // If content is empty, deletes any existing file so stale patches from
86
+ // previous turns aren't re-uploaded by the bot.
87
+ async function writePatch({ dir, name, content, }) {
88
+ const target = path.join(dir, name);
89
+ if (!content) {
90
+ await fs.promises.unlink(target).catch(() => { });
91
+ return;
92
+ }
93
+ await fs.promises.mkdir(dir, { recursive: true });
94
+ const tmp = `${target}.tmp.${process.pid}`;
95
+ await fs.promises.writeFile(tmp, content);
96
+ await fs.promises.rename(tmp, target);
97
+ await prunePatchDir(dir);
98
+ }
99
+ const diffPatchPlugin = async (input) => {
100
+ const dataDir = process.env.KIMAKI_DATA_DIR || path.join(process.env.HOME || '~', '.kimaki');
101
+ const dir = path.join(dataDir, 'diffs');
102
+ // session → file → { first snapshot, last snapshot }
103
+ const sessions = new Map();
104
+ // session → messageId → file → { first, last }
105
+ const messages = new Map();
106
+ // callID → pending tool info (for matching before/after)
107
+ const pending = new Map();
108
+ // session → current active assistant messageId
109
+ const activeMessage = new Map();
110
+ // dedup guard: "sessionID:messageID:completedTimestamp"
111
+ const completed = new Set();
112
+ // child session → root (top-level) parent session.
113
+ // When a task tool spawns a subtask, tools inside that subtask fire with
114
+ // the child sessionID. We resolve up to the root parent so file snapshots
115
+ // always roll up into the top-level session's cumulative patch.
116
+ const childToRoot = new Map();
117
+ /** Resolve up the parent chain to find the root (top-level) session. */
118
+ function resolveRoot(sessionID) {
119
+ return childToRoot.get(sessionID) ?? sessionID;
120
+ }
121
+ function ensureSession(sessionID) {
122
+ if (!sessions.has(sessionID)) {
123
+ sessions.set(sessionID, new Map());
124
+ }
125
+ if (!messages.has(sessionID)) {
126
+ messages.set(sessionID, new Map());
127
+ }
128
+ }
129
+ function ensureMessage(sessionID, messageID) {
130
+ const msgs = messages.get(sessionID);
131
+ if (!msgs.has(messageID)) {
132
+ msgs.set(messageID, new Map());
133
+ }
134
+ }
135
+ function setFirst(map, file, content) {
136
+ if (!map.has(file)) {
137
+ map.set(file, { first: content, last: content });
138
+ }
139
+ }
140
+ function prunePending() {
141
+ const now = Date.now();
142
+ for (const [id, item] of pending) {
143
+ if (now - item.createdAt > PENDING_TTL_MS) {
144
+ pending.delete(id);
145
+ }
146
+ }
147
+ }
148
+ function cleanupSession(sessionID) {
149
+ sessions.delete(sessionID);
150
+ messages.delete(sessionID);
151
+ activeMessage.delete(sessionID);
152
+ for (const [id, p] of pending) {
153
+ if (p.sessionID === sessionID) {
154
+ pending.delete(id);
155
+ }
156
+ }
157
+ for (const key of completed) {
158
+ if (key.startsWith(sessionID + ':')) {
159
+ completed.delete(key);
160
+ }
161
+ }
162
+ // Clean up child→root mappings pointing to or from this session
163
+ for (const [child, root] of childToRoot) {
164
+ if (child === sessionID || root === sessionID) {
165
+ childToRoot.delete(child);
166
+ }
167
+ }
168
+ }
169
+ return {
170
+ 'tool.execute.before': async ({ tool, sessionID, callID }, { args }) => {
171
+ if (!TOOLS.has(tool)) {
172
+ return;
173
+ }
174
+ const raw = extractPaths(tool, args);
175
+ if (!raw.length) {
176
+ return;
177
+ }
178
+ prunePending();
179
+ // Resolve root session so child edits roll up into the parent's patch.
180
+ const rootID = resolveRoot(sessionID);
181
+ ensureSession(rootID);
182
+ const rootFiles = sessions.get(rootID);
183
+ const msgID = activeMessage.get(rootID);
184
+ const captured = [];
185
+ for (const r of raw) {
186
+ const p = path.isAbsolute(r)
187
+ ? r
188
+ : path.resolve(input.directory, r);
189
+ const content = await readSafe(p);
190
+ if (content === undefined) {
191
+ continue;
192
+ }
193
+ setFirst(rootFiles, p, content);
194
+ if (msgID) {
195
+ ensureMessage(rootID, msgID);
196
+ setFirst(messages.get(rootID).get(msgID), p, content);
197
+ }
198
+ captured.push(p);
199
+ }
200
+ if (captured.length) {
201
+ pending.set(callID, {
202
+ // Store rootID so tool.execute.after updates the correct maps
203
+ sessionID: rootID,
204
+ messageID: msgID,
205
+ paths: captured,
206
+ createdAt: Date.now(),
207
+ });
208
+ }
209
+ },
210
+ 'tool.execute.after': async ({ callID }) => {
211
+ prunePending();
212
+ const call = pending.get(callID);
213
+ if (!call) {
214
+ return;
215
+ }
216
+ pending.delete(callID);
217
+ const sessionFiles = sessions.get(call.sessionID);
218
+ // Use messageID captured at before-time, not current activeMessage
219
+ const msgFiles = call.messageID
220
+ ? messages.get(call.sessionID)?.get(call.messageID)
221
+ : undefined;
222
+ for (const p of call.paths) {
223
+ const content = await readSafe(p);
224
+ if (content === undefined) {
225
+ continue;
226
+ }
227
+ const s = sessionFiles.get(p);
228
+ if (s) {
229
+ s.last = content;
230
+ }
231
+ if (msgFiles) {
232
+ const m = msgFiles.get(p);
233
+ if (m) {
234
+ m.last = content;
235
+ }
236
+ }
237
+ }
238
+ },
239
+ event: async ({ event }) => {
240
+ // Track child→root session mapping from session.updated events.
241
+ // OpenCode emits session.updated with parentID set when a task tool
242
+ // creates a child session. This fires BEFORE the child starts executing
243
+ // tools, so the mapping is ready when child tool.execute.before fires.
244
+ if (event.type === 'session.updated') {
245
+ const info = event.properties.info;
246
+ if (info.id && info.parentID) {
247
+ const rootID = resolveRoot(info.parentID);
248
+ childToRoot.set(info.id, rootID);
249
+ }
250
+ }
251
+ if (event.type === 'message.updated' &&
252
+ event.properties.info.role === 'assistant') {
253
+ const msg = event.properties.info;
254
+ activeMessage.set(msg.sessionID, msg.id);
255
+ if (msg.time.completed) {
256
+ const key = `${msg.sessionID}:${msg.id}:${msg.time.completed}`;
257
+ if (completed.has(key)) {
258
+ return;
259
+ }
260
+ completed.add(key);
261
+ const msgFiles = messages.get(msg.sessionID)?.get(msg.id);
262
+ if (msgFiles?.size) {
263
+ const patch = buildPatch(msgFiles);
264
+ await writePatch({
265
+ dir,
266
+ name: `${msg.sessionID}-${msg.id}.patch`,
267
+ content: patch,
268
+ });
269
+ }
270
+ messages.get(msg.sessionID)?.delete(msg.id);
271
+ }
272
+ return;
273
+ }
274
+ if (event.type === 'session.idle') {
275
+ const sessionID = event.properties.sessionID;
276
+ // Child sessions: edits already rolled up into root, skip patch write.
277
+ if (childToRoot.has(sessionID)) {
278
+ return;
279
+ }
280
+ const sessionFiles = sessions.get(sessionID);
281
+ if (sessionFiles?.size) {
282
+ const patch = buildPatch(sessionFiles);
283
+ await writePatch({
284
+ dir,
285
+ name: `${sessionID}.patch`,
286
+ content: patch,
287
+ });
288
+ // Reset first snapshots to current state so next turn is incremental
289
+ for (const state of sessionFiles.values()) {
290
+ state.first = state.last;
291
+ }
292
+ }
293
+ messages.get(sessionID)?.clear();
294
+ activeMessage.delete(sessionID);
295
+ for (const [id, p] of pending) {
296
+ if (p.sessionID === sessionID) {
297
+ pending.delete(id);
298
+ }
299
+ }
300
+ // Clear completed dedup keys for this session to bound memory growth
301
+ for (const key of completed) {
302
+ if (key.startsWith(sessionID + ':')) {
303
+ completed.delete(key);
304
+ }
305
+ }
306
+ return;
307
+ }
308
+ if (event.type === 'session.deleted') {
309
+ cleanupSession(event.properties.info.id);
310
+ }
311
+ },
312
+ };
313
+ };
314
+ export default diffPatchPlugin;
@@ -14,6 +14,7 @@ import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './
14
14
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
15
15
  import { cancelPendingQuestion } from './commands/ask-question.js';
16
16
  import { cancelPendingFileUpload } from './commands/file-upload.js';
17
+ import { cancelPendingPermission } from './commands/permissions.js';
17
18
  import { cancelHtmlActionsForThread } from './html-actions.js';
18
19
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
19
20
  import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
@@ -330,19 +331,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
330
331
  if (isThread) {
331
332
  const thread = channel;
332
333
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
333
- // Cancel interactive UI when a real user sends a message.
334
- // If a question was pending and answered with the user's text,
335
- // early-return: the message was consumed as the question answer
336
- // and must NOT also be sent as a new prompt (causes abort loops).
337
- if (!message.author.bot && !isCliInjectedPrompt) {
338
- cancelPendingActionButtons(thread.id);
339
- cancelHtmlActionsForThread(thread.id);
340
- const questionResult = await cancelPendingQuestion(thread.id, message.content);
341
- void cancelPendingFileUpload(thread.id);
342
- if (questionResult === 'replied') {
343
- return;
344
- }
345
- }
346
334
  const parent = thread.parent;
347
335
  let projectDirectory;
348
336
  if (parent) {
@@ -424,6 +412,23 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
424
412
  channelId: parent?.id || undefined,
425
413
  appId: currentAppId,
426
414
  });
415
+ // Cancel interactive UI when a real user sends a message.
416
+ // If a question was pending and answered with the user's text,
417
+ // early-return: the message was consumed as the question answer
418
+ // and must NOT also be sent as a new prompt (causes abort loops).
419
+ if (!message.author.bot && !isCliInjectedPrompt) {
420
+ cancelPendingActionButtons(thread.id);
421
+ cancelHtmlActionsForThread(thread.id);
422
+ const dismissedPermission = await cancelPendingPermission(thread.id);
423
+ if (dismissedPermission) {
424
+ runtime.abortActiveRun('user sent a new message while permission was pending');
425
+ }
426
+ const questionResult = await cancelPendingQuestion(thread.id, message.content);
427
+ void cancelPendingFileUpload(thread.id);
428
+ if (questionResult === 'replied') {
429
+ return;
430
+ }
431
+ }
427
432
  // Expensive pre-processing (voice transcription, context fetch,
428
433
  // attachment download) runs inside the runtime's serialized
429
434
  // preprocess chain, preserving Discord arrival order without
@@ -594,7 +599,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
594
599
  });
595
600
  }
596
601
  else {
597
- discordLogger.log(`Channel type ${channel.type} is not supported`);
602
+ // discordLogger.log(`Channel type ${channel.type} is not supported`)
598
603
  }
599
604
  }
600
605
  catch (error) {
@@ -0,0 +1,62 @@
1
+ // Guardrail test for adapter boundary imports.
2
+ // Runtime modules must not import discord.js directly outside the Discord adapter,
3
+ // forum-sync bridge, and voice handler.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { describe, expect, test } from 'vitest';
7
+ const SRC_DIR = path.resolve(import.meta.dirname);
8
+ function collectTsFiles(dir) {
9
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
10
+ return entries.flatMap((entry) => {
11
+ const fullPath = path.join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ return collectTsFiles(fullPath);
14
+ }
15
+ if (!entry.name.endsWith('.ts')) {
16
+ return [];
17
+ }
18
+ if (entry.name.endsWith('.test.ts')) {
19
+ return [];
20
+ }
21
+ if (entry.name.includes('e2e')) {
22
+ return [];
23
+ }
24
+ return [fullPath];
25
+ });
26
+ }
27
+ function toWorkspaceRelative(filePath) {
28
+ return path.relative(path.resolve(import.meta.dirname, '..'), filePath);
29
+ }
30
+ function isAllowedBoundaryFile(relativePath) {
31
+ if (relativePath === 'src/platform/discord-adapter.ts') {
32
+ return true;
33
+ }
34
+ if (relativePath === 'src/voice-handler.ts') {
35
+ return true;
36
+ }
37
+ if (relativePath.startsWith('src/forum-sync/')) {
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ describe('discord.js import boundary', () => {
43
+ test('does not import discord.js outside allowed modules', () => {
44
+ const violations = collectTsFiles(SRC_DIR)
45
+ .map((filePath) => {
46
+ return {
47
+ relativePath: toWorkspaceRelative(filePath),
48
+ content: fs.readFileSync(filePath, 'utf8'),
49
+ };
50
+ })
51
+ .filter(({ relativePath }) => {
52
+ return !isAllowedBoundaryFile(relativePath);
53
+ })
54
+ .filter(({ content }) => {
55
+ return /from\s+['"]discord\.js['"]/.test(content);
56
+ })
57
+ .map(({ relativePath }) => {
58
+ return relativePath;
59
+ });
60
+ expect(violations).toMatchInlineSnapshot(`[]`);
61
+ });
62
+ });
@@ -480,6 +480,50 @@ export async function getKimakiMetadata(textChannel) {
480
480
  projectDirectory: channelConfig.directory,
481
481
  };
482
482
  }
483
+ /**
484
+ * Resolve project directory from an autocomplete interaction.
485
+ * Uses interaction.channelId (always available from raw payload) instead of
486
+ * interaction.channel (cache-based getter, often null with gateway-proxy).
487
+ * Checks the channel ID directly in DB, then tries thread worktree lookup,
488
+ * then falls back to fetching the channel to resolve thread parent.
489
+ */
490
+ export async function resolveProjectDirectoryFromAutocomplete(interaction) {
491
+ const channelId = interaction.channelId;
492
+ // Direct channel lookup — works when the command is run from a project text channel
493
+ const channelConfig = await getChannelDirectory(channelId);
494
+ if (channelConfig) {
495
+ return channelConfig.directory;
496
+ }
497
+ // If we're in a thread, try worktree info first (has project_directory)
498
+ const worktreeInfo = await getThreadWorktree(channelId);
499
+ if (worktreeInfo?.project_directory) {
500
+ return worktreeInfo.project_directory;
501
+ }
502
+ // Thread fallback: resolve parent channel ID and look up its directory.
503
+ // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
504
+ const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null;
505
+ if (cachedParentId) {
506
+ const parentConfig = await getChannelDirectory(cachedParentId);
507
+ if (parentConfig) {
508
+ return parentConfig.directory;
509
+ }
510
+ }
511
+ // Last resort: fetch the channel from Discord API to get parentId for threads
512
+ // when the channel isn't cached at all (common with gateway-proxy).
513
+ if (!cachedParentId) {
514
+ const fetched = await errore.tryAsync({
515
+ try: () => { return interaction.client.channels.fetch(channelId); },
516
+ catch: (e) => { return e; },
517
+ });
518
+ if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
519
+ const parentConfig = await getChannelDirectory(fetched.parentId);
520
+ if (parentConfig) {
521
+ return parentConfig.directory;
522
+ }
523
+ }
524
+ }
525
+ return undefined;
526
+ }
483
527
  /**
484
528
  * Resolve the working directory for a channel or thread.
485
529
  * Returns both the base project directory (for server init) and the working directory
@@ -2,7 +2,6 @@
2
2
  // Uses opencode-cached-provider + Gemini to record real tool/lifecycle streams
3
3
  // (task, interruption, permission, action buttons, and question flows).
4
4
  import fs from 'node:fs';
5
- import net from 'node:net';
6
5
  import path from 'node:path';
7
6
  import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
8
7
  import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
@@ -13,7 +12,7 @@ import { store } from './store.js';
13
12
  import { startDiscordBot } from './discord-bot.js';
14
13
  import { closeDatabase, getChannelVerbosity, initDatabase, setBotToken, setChannelDirectory, setChannelVerbosity, } from './database.js';
15
14
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
16
- import { cleanupTestSessions } from './test-utils.js';
15
+ import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
17
16
  import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js';
18
17
  import { stopOpencodeServer } from './opencode.js';
19
18
  import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js';
@@ -46,23 +45,6 @@ function createRunDirectories() {
46
45
  fixtureOutputDir,
47
46
  };
48
47
  }
49
- function chooseLockPort() {
50
- return new Promise((resolve, reject) => {
51
- const server = net.createServer();
52
- server.listen(0, () => {
53
- const address = server.address();
54
- if (!address || typeof address === 'string') {
55
- server.close();
56
- reject(new Error('Failed to resolve lock port'));
57
- return;
58
- }
59
- const port = address.port;
60
- server.close(() => {
61
- resolve(port);
62
- });
63
- });
64
- });
65
- }
66
48
  function createDiscordJsClient({ restUrl }) {
67
49
  return new Client({
68
50
  intents: [
@@ -248,7 +230,7 @@ describe('real event stream capture fixtures (cached provider)', () => {
248
230
  }
249
231
  beforeAll(async () => {
250
232
  testStartTime = Date.now();
251
- lockPort = await chooseLockPort();
233
+ lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
252
234
  listJsonlFiles(directories.sessionEventsDir).forEach((fileName) => {
253
235
  fs.rmSync(path.join(directories.sessionEventsDir, fileName), {
254
236
  force: true,
@@ -19,7 +19,7 @@ import { startHranaServer, stopHranaServer } from './hrana-server.js';
19
19
  import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, } from './database.js';
20
20
  import { setDataDir } from './config.js';
21
21
  import { startDiscordBot } from './discord-bot.js';
22
- import { cleanupTestSessions, waitForFooterMessage, } from './test-utils.js';
22
+ import { chooseLockPort, cleanupTestSessions, waitForFooterMessage, } from './test-utils.js';
23
23
  import { stopOpencodeServer } from './opencode.js';
24
24
  import { createDiscordRest } from './discord-urls.js';
25
25
  import { store } from './store.js';
@@ -57,9 +57,6 @@ function createRunDirectories() {
57
57
  fs.mkdirSync(projectDirectory, { recursive: true });
58
58
  return { root, dataDir, projectDirectory };
59
59
  }
60
- function chooseLockPort() {
61
- return getAvailablePort();
62
- }
63
60
  function createDiscordJsClient({ restUrl }) {
64
61
  return new Client({
65
62
  intents: [
@@ -189,7 +186,7 @@ describeIf('gateway-proxy e2e', () => {
189
186
  let testStartTime = Date.now();
190
187
  beforeAll(async () => {
191
188
  testStartTime = Date.now();
192
- const lockPort = await chooseLockPort();
189
+ const lockPort = chooseLockPort({ key: CHANNEL_1_ID });
193
190
  directories = createRunDirectories();
194
191
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
195
192
  process.env['KIMAKI_VITEST'] = '1';
@@ -0,0 +1,17 @@
1
+ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
+ /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
4
+ // @ts-nocheck
5
+ /*
6
+ * This file should be your main import to use Prisma-related types and utilities in a browser.
7
+ * Use it to get access to models, enums, and input types.
8
+ *
9
+ * This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
10
+ * See `client.ts` for the standard, server-side entry point.
11
+ *
12
+ * 🟢 You can import this file directly.
13
+ */
14
+ import * as Prisma from './internal/prismaNamespaceBrowser.js';
15
+ export { Prisma };
16
+ export * as $Enums from './enums.js';
17
+ export * from './enums.js';
@@ -0,0 +1,34 @@
1
+ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
+ /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
4
+ // @ts-nocheck
5
+ /*
6
+ * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
7
+ * If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
8
+ *
9
+ * 🟢 You can import this file directly.
10
+ */
11
+ globalThis['__dirname'] = '/';
12
+ import * as runtime from "@prisma/client/runtime/wasm-compiler-edge";
13
+ import * as $Enums from "./enums.js";
14
+ import * as $Class from "./internal/class.js";
15
+ import * as Prisma from "./internal/prismaNamespace.js";
16
+ export * as $Enums from './enums.js';
17
+ export * from "./enums.js";
18
+ /**
19
+ * ## Prisma Client
20
+ *
21
+ * Type-safe database client for TypeScript
22
+ * @example
23
+ * ```
24
+ * const prisma = new PrismaClient({
25
+ * adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
26
+ * })
27
+ * // Fetch zero or more Thread_sessions
28
+ * const thread_sessions = await prisma.thread_sessions.findMany()
29
+ * ```
30
+ *
31
+ * Read more in our [docs](https://pris.ly/d/client).
32
+ */
33
+ export const PrismaClient = $Class.getPrismaClientClass();
34
+ export { Prisma };
@@ -0,0 +1,10 @@
1
+ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
+ /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
4
+ // @ts-nocheck
5
+ /*
6
+ * This file exports various common sort, input & filter types that are not directly linked to a particular model.
7
+ *
8
+ * 🟢 You can import this file directly.
9
+ */
10
+ import * as $Enums from "./enums.js";
@@ -0,0 +1,48 @@
1
+ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
+ /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
4
+ // @ts-nocheck
5
+ /*
6
+ * This file exports all enum related types from the schema.
7
+ *
8
+ * 🟢 You can import this file directly.
9
+ */
10
+ export const BotMode = {
11
+ self_hosted: 'self_hosted',
12
+ gateway: 'gateway'
13
+ };
14
+ export const ChannelType = {
15
+ text: 'text',
16
+ voice: 'voice'
17
+ };
18
+ export const WorktreeStatus = {
19
+ pending: 'pending',
20
+ ready: 'ready',
21
+ error: 'error'
22
+ };
23
+ export const VerbosityLevel = {
24
+ tools_and_text: 'tools_and_text',
25
+ text_and_essential_tools: 'text_and_essential_tools',
26
+ text_only: 'text_only'
27
+ };
28
+ export const task_status = {
29
+ planned: 'planned',
30
+ running: 'running',
31
+ completed: 'completed',
32
+ cancelled: 'cancelled',
33
+ failed: 'failed'
34
+ };
35
+ export const task_schedule_kind = {
36
+ at: 'at',
37
+ cron: 'cron'
38
+ };
39
+ export const ipc_request_type = {
40
+ file_upload: 'file_upload',
41
+ action_buttons: 'action_buttons'
42
+ };
43
+ export const ipc_request_status = {
44
+ pending: 'pending',
45
+ processing: 'processing',
46
+ completed: 'completed',
47
+ cancelled: 'cancelled'
48
+ };