kimaki 0.26.0 → 0.28.0
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.
- package/dist/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
package/dist/discord-bot.js
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
// suppresses these when shuttingDown is already set, and we removeAllListeners()
|
|
9
9
|
// before destroying the client to prevent late events from becoming uncaught.
|
|
10
10
|
import { DiscordOperationError } from './errors.js';
|
|
11
|
-
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
11
|
+
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
12
12
|
import { stopOpencodeServer, } from './opencode.js';
|
|
13
13
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
14
14
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
15
15
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
16
|
+
import { STATUS_PREFIX } from './message-formatting.js';
|
|
16
17
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
17
18
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
18
19
|
import YAML from 'yaml';
|
|
@@ -29,7 +30,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
|
29
30
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
30
31
|
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
31
32
|
import {} from './session-handler/model-utils.js';
|
|
32
|
-
import { getRuntime, getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
|
|
33
|
+
import { getRuntime, getOrCreateRuntime, disposeRuntime, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
|
|
33
34
|
import { runShellCommand } from './commands/run-command.js';
|
|
34
35
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
35
36
|
import { getDiscordRestApiUrl } from './discord-urls.js';
|
|
@@ -42,7 +43,7 @@ import { getDefaultKimakiDirectory, getUserProjectCount, } from './channel-manag
|
|
|
42
43
|
import { store } from './store.js';
|
|
43
44
|
import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
|
|
44
45
|
export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
|
|
45
|
-
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
46
|
+
export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from './opencode.js';
|
|
46
47
|
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
47
48
|
export { getOpencodeSystemMessage } from './system-message.js';
|
|
48
49
|
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
|
|
@@ -52,6 +53,7 @@ import path from 'node:path';
|
|
|
52
53
|
import dedent from 'string-dedent';
|
|
53
54
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
54
55
|
import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
|
|
56
|
+
import { flushCpuProfiling, startStdinCpuProfListener, stopStdinCpuProfListener, } from './cpu-profiler.js';
|
|
55
57
|
import { startTaskRunner } from './task-runner.js';
|
|
56
58
|
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
57
59
|
// Each session's event.subscribe() holds a connection; without enough connections,
|
|
@@ -277,8 +279,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
277
279
|
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
278
280
|
if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
279
281
|
discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
|
|
280
|
-
// Self-restart: cleanup, then
|
|
281
|
-
// restarts us. Without the wrapper this
|
|
282
|
+
// Self-restart: cleanup, then SIGKILL so the bin.ts wrapper
|
|
283
|
+
// restarts us. Without the wrapper this dies after logging a warning.
|
|
282
284
|
void selfRestart('gateway-reconnect-limit');
|
|
283
285
|
}
|
|
284
286
|
});
|
|
@@ -306,524 +308,554 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
306
308
|
discordLogger.error('[GATEWAY] Session invalidated by Discord');
|
|
307
309
|
});
|
|
308
310
|
discordClient.on(Events.MessageCreate, async (message) => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
347
|
-
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
348
|
-
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
349
|
-
const member = await resolveGuildMessageMember(message);
|
|
350
|
-
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
// Detect messages that start with a mention of another user (not the bot).
|
|
355
|
-
// In channels these are fully ignored. In threads they are added to the
|
|
356
|
-
// session context without triggering the AI (noReply), so the agent sees
|
|
357
|
-
// user-to-user conversation on the next real turn.
|
|
358
|
-
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
359
|
-
const isLeadingMentionToOtherUser = leadingMentionMatch &&
|
|
360
|
-
leadingMentionMatch[1] !== discordClient.user?.id;
|
|
361
|
-
if (message.partial) {
|
|
362
|
-
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
363
|
-
const fetched = await message.fetch()
|
|
364
|
-
.catch((e) => new DiscordOperationError({ operation: 'fetchMessage', cause: e }));
|
|
365
|
-
if (fetched instanceof Error) {
|
|
366
|
-
discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
|
|
311
|
+
const threadIngressSlot = message.channel.isThread() || message.channel.type === ChannelType.GuildText
|
|
312
|
+
? reserveThreadIngress(message.channel.id)
|
|
313
|
+
: undefined;
|
|
314
|
+
await runInThreadIngressSlot(threadIngressSlot, async () => {
|
|
315
|
+
try {
|
|
316
|
+
const isSelfBotMessage = Boolean(discordClient.user && message.author?.id === discordClient.user.id);
|
|
317
|
+
const promptMarker = parseEmbedFooterMarker({
|
|
318
|
+
footer: message.embeds[0]?.footer?.text,
|
|
319
|
+
});
|
|
320
|
+
const isCliInjectedPrompt = Boolean(isSelfBotMessage && isInjectedPromptMarker({ marker: promptMarker }));
|
|
321
|
+
const sessionStartSource = isCliInjectedPrompt
|
|
322
|
+
? parseSessionStartSourceFromMarker(promptMarker)
|
|
323
|
+
: undefined;
|
|
324
|
+
const cliInjectedUsername = isCliInjectedPrompt
|
|
325
|
+
? promptMarker?.username || 'kimaki-cli'
|
|
326
|
+
: undefined;
|
|
327
|
+
const cliInjectedUserId = isCliInjectedPrompt
|
|
328
|
+
? promptMarker?.userId
|
|
329
|
+
: undefined;
|
|
330
|
+
const cliInjectedAgent = isCliInjectedPrompt
|
|
331
|
+
? promptMarker?.agent
|
|
332
|
+
: undefined;
|
|
333
|
+
const cliInjectedModel = isCliInjectedPrompt
|
|
334
|
+
? promptMarker?.model
|
|
335
|
+
: undefined;
|
|
336
|
+
const cliInjectedPermissions = isCliInjectedPrompt
|
|
337
|
+
? promptMarker?.permissions
|
|
338
|
+
: undefined;
|
|
339
|
+
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
340
|
+
? promptMarker?.injectionGuardPatterns
|
|
341
|
+
: undefined;
|
|
342
|
+
const cliInjectedParentSessionId = isCliInjectedPrompt
|
|
343
|
+
? promptMarker?.parentSessionId
|
|
344
|
+
: undefined;
|
|
345
|
+
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
346
|
+
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
347
|
+
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
367
348
|
return;
|
|
368
349
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// This prevents permission-error replies for user-to-user conversation.
|
|
377
|
-
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
381
|
-
const mentionModeEnabled = await getChannelMentionMode(channel.id);
|
|
382
|
-
if (mentionModeEnabled) {
|
|
383
|
-
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
384
|
-
const isShellCommand = message.content?.startsWith('!');
|
|
385
|
-
if (!botMentioned && !isShellCommand) {
|
|
386
|
-
voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`);
|
|
350
|
+
// Allow CLI-injected prompts from this Kimaki bot through even when role
|
|
351
|
+
// reconciliation did not give the bot the "Kimaki" role yet. Other bots
|
|
352
|
+
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
353
|
+
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
354
|
+
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
355
|
+
const member = await resolveGuildMessageMember(message);
|
|
356
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
387
357
|
return;
|
|
388
358
|
}
|
|
389
359
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const thread = channel;
|
|
404
|
-
owningChannelId = thread.parent?.id || thread.parentId || undefined;
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
owningChannelId = channel.id;
|
|
408
|
-
}
|
|
409
|
-
if (owningChannelId) {
|
|
410
|
-
const channelConfig = await getChannelDirectory(owningChannelId);
|
|
411
|
-
if (!channelConfig) {
|
|
412
|
-
voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
|
|
360
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
361
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
362
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
363
|
+
// user-to-user conversation on the next real turn.
|
|
364
|
+
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
365
|
+
const isLeadingMentionToOtherUser = leadingMentionMatch &&
|
|
366
|
+
leadingMentionMatch[1] !== discordClient.user?.id;
|
|
367
|
+
if (message.partial) {
|
|
368
|
+
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
369
|
+
const fetched = await message.fetch()
|
|
370
|
+
.catch((e) => new DiscordOperationError({ operation: 'fetchMessage', cause: e }));
|
|
371
|
+
if (fetched instanceof Error) {
|
|
372
|
+
discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
|
|
413
373
|
return;
|
|
414
374
|
}
|
|
415
375
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
425
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
426
|
-
});
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
430
|
-
await message.reply({
|
|
431
|
-
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
432
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
433
|
-
});
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
const isThread = [
|
|
438
|
-
ChannelType.PublicThread,
|
|
439
|
-
ChannelType.PrivateThread,
|
|
440
|
-
ChannelType.AnnouncementThread,
|
|
441
|
-
].includes(channel.type);
|
|
442
|
-
if (isThread) {
|
|
443
|
-
const thread = channel;
|
|
444
|
-
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
445
|
-
// Only respond in threads kimaki knows about (has a session row in DB),
|
|
446
|
-
// where the bot is explicitly @mentioned, or where the bot created the
|
|
447
|
-
// thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
|
|
448
|
-
// the bot from hijacking user-created threads in project channels while
|
|
449
|
-
// still responding to bot-created threads that may not yet have a session
|
|
450
|
-
// row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
|
|
451
|
-
const hasExistingSession = await getThreadSession(thread.id);
|
|
452
|
-
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
453
|
-
const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
|
|
454
|
-
if (!hasExistingSession &&
|
|
455
|
-
!botMentioned &&
|
|
456
|
-
!isCliInjectedPrompt &&
|
|
457
|
-
!botCreatedThread) {
|
|
458
|
-
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
376
|
+
// Check mention mode BEFORE permission check for text channels.
|
|
377
|
+
// When mention mode is enabled, users without Kimaki role can message
|
|
378
|
+
// without getting a permission error - we just silently ignore.
|
|
379
|
+
const channel = message.channel;
|
|
380
|
+
// In text channels, messages starting with a mention to another user
|
|
381
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
382
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
383
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
459
384
|
return;
|
|
460
385
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
386
|
+
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
387
|
+
const mentionModeEnabled = await getChannelMentionMode(channel.id);
|
|
388
|
+
if (mentionModeEnabled) {
|
|
389
|
+
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
390
|
+
const isShellCommand = message.content?.startsWith('!');
|
|
391
|
+
if (!botMentioned && !isShellCommand) {
|
|
392
|
+
voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
466
396
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
397
|
+
// Multi-machine routing: check channel ownership before permission
|
|
398
|
+
// checks so we don't send permission-denial replies in channels owned
|
|
399
|
+
// by another machine. Resolve the "owning" channel: for threads, check
|
|
400
|
+
// the parent channel; for text channels, check the channel itself.
|
|
401
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
402
|
+
const channel = message.channel;
|
|
403
|
+
let owningChannelId;
|
|
404
|
+
if ([
|
|
405
|
+
ChannelType.PublicThread,
|
|
406
|
+
ChannelType.PrivateThread,
|
|
407
|
+
ChannelType.AnnouncementThread,
|
|
408
|
+
].includes(channel.type)) {
|
|
409
|
+
const thread = channel;
|
|
410
|
+
owningChannelId = thread.parent?.id || thread.parentId || undefined;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
owningChannelId = channel.id;
|
|
414
|
+
}
|
|
415
|
+
if (owningChannelId) {
|
|
416
|
+
const channelConfig = await getChannelDirectory(owningChannelId);
|
|
417
|
+
if (!channelConfig) {
|
|
418
|
+
voiceLogger.log(`[IGNORED] Channel ${owningChannelId} has no project directory configured`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
473
421
|
}
|
|
474
422
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
423
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
424
|
+
const member = await resolveGuildMessageMember(message);
|
|
425
|
+
if (!member) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (hasNoKimakiRole(member)) {
|
|
429
|
+
await message.reply({
|
|
430
|
+
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
431
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
484
436
|
await message.reply({
|
|
485
|
-
content: '
|
|
437
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
486
438
|
flags: SILENT_MESSAGE_FLAGS,
|
|
487
439
|
});
|
|
488
440
|
return;
|
|
489
441
|
}
|
|
490
|
-
|
|
442
|
+
}
|
|
443
|
+
const isThread = [
|
|
444
|
+
ChannelType.PublicThread,
|
|
445
|
+
ChannelType.PrivateThread,
|
|
446
|
+
ChannelType.AnnouncementThread,
|
|
447
|
+
].includes(channel.type);
|
|
448
|
+
if (isThread) {
|
|
449
|
+
const thread = channel;
|
|
450
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
451
|
+
// Only respond in threads kimaki knows about (has a session row in DB),
|
|
452
|
+
// where the bot is explicitly @mentioned, or where the bot created the
|
|
453
|
+
// thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
|
|
454
|
+
// the bot from hijacking user-created threads in project channels while
|
|
455
|
+
// still responding to bot-created threads that may not yet have a session
|
|
456
|
+
// row with a non-empty session_id (createPendingWorkspace sets ''). (GitHub #84)
|
|
457
|
+
const hasExistingSession = await getThreadSession(thread.id);
|
|
458
|
+
const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
|
|
459
|
+
const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
|
|
460
|
+
if (!hasExistingSession &&
|
|
461
|
+
!botMentioned &&
|
|
462
|
+
!isCliInjectedPrompt &&
|
|
463
|
+
!botCreatedThread) {
|
|
464
|
+
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
468
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
469
|
+
// preprocessing for nothing.
|
|
470
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const parent = thread.parent;
|
|
474
|
+
let projectDirectory;
|
|
475
|
+
if (parent) {
|
|
476
|
+
const channelConfig = await getChannelDirectory(parent.id);
|
|
477
|
+
if (channelConfig) {
|
|
478
|
+
projectDirectory = channelConfig.directory;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Check if this thread is a worktree thread.
|
|
482
|
+
// When the runtime exists in memory, pending worktrees are handled by
|
|
483
|
+
// the preprocess chain (messages queue behind the worktree promise).
|
|
484
|
+
// After a bot restart the runtime is gone, so we must reject messages
|
|
485
|
+
// for pending worktrees to avoid running in the base directory.
|
|
486
|
+
// Check both thread_workspaces (new) and thread_worktrees (legacy)
|
|
487
|
+
const worktreeInfo = await getThreadWorktreeOrWorkspace(thread.id);
|
|
488
|
+
if (worktreeInfo) {
|
|
489
|
+
if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
|
|
490
|
+
await message.reply({
|
|
491
|
+
content: '⏳ Worktree is still being created. Please wait...',
|
|
492
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (worktreeInfo.status === 'error') {
|
|
497
|
+
await message.reply({
|
|
498
|
+
content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
|
|
499
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Use original project directory for OpenCode server (session lives there)
|
|
504
|
+
// The worktree directory is passed via query.directory in prompt/command calls
|
|
505
|
+
if (worktreeInfo.project_directory) {
|
|
506
|
+
projectDirectory = worktreeInfo.project_directory;
|
|
507
|
+
discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.workspace_directory})`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
511
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
491
512
|
await message.reply({
|
|
492
|
-
content:
|
|
513
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
493
514
|
flags: NOTIFY_MESSAGE_FLAGS,
|
|
494
515
|
});
|
|
495
516
|
return;
|
|
496
517
|
}
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
518
|
+
// ! prefix runs a shell command instead of starting/continuing a session.
|
|
519
|
+
// Use worktree directory if available, so commands run in the worktree cwd.
|
|
520
|
+
// Skip shell commands while worktree is pending — they'd run in the base dir.
|
|
521
|
+
if (message.content?.startsWith('!') &&
|
|
522
|
+
projectDirectory &&
|
|
523
|
+
worktreeInfo?.status !== 'pending') {
|
|
524
|
+
const shellCmd = message.content.slice(1).trim();
|
|
525
|
+
if (shellCmd) {
|
|
526
|
+
threadIngressSlot?.release();
|
|
527
|
+
const shellDir = worktreeInfo?.status === 'ready' &&
|
|
528
|
+
worktreeInfo.workspace_directory
|
|
529
|
+
? worktreeInfo.workspace_directory
|
|
530
|
+
: projectDirectory;
|
|
531
|
+
const loadingReply = await message.reply({
|
|
532
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
533
|
+
});
|
|
534
|
+
const result = await runShellCommand({
|
|
535
|
+
command: shellCmd,
|
|
536
|
+
directory: shellDir,
|
|
537
|
+
});
|
|
538
|
+
await loadingReply.edit({ content: result });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
502
541
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// ! prefix runs a shell command instead of starting/continuing a session.
|
|
513
|
-
// Use worktree directory if available, so commands run in the worktree cwd.
|
|
514
|
-
// Skip shell commands while worktree is pending — they'd run in the base dir.
|
|
515
|
-
if (message.content?.startsWith('!') &&
|
|
516
|
-
projectDirectory &&
|
|
517
|
-
worktreeInfo?.status !== 'pending') {
|
|
518
|
-
const shellCmd = message.content.slice(1).trim();
|
|
519
|
-
if (shellCmd) {
|
|
520
|
-
const shellDir = worktreeInfo?.status === 'ready' &&
|
|
542
|
+
// `. btw` suffix mirrors /btw for fast side-question forks.
|
|
543
|
+
// Works like queue: just the word "btw" at the end after punctuation
|
|
544
|
+
// or newline. The whole message (minus the suffix) becomes the fork prompt.
|
|
545
|
+
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
546
|
+
? extractBtwSuffix(message.content || '')
|
|
547
|
+
: null;
|
|
548
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
549
|
+
threadIngressSlot?.release();
|
|
550
|
+
const btwSdkDir = worktreeInfo?.status === 'ready' &&
|
|
521
551
|
worktreeInfo.workspace_directory
|
|
522
552
|
? worktreeInfo.workspace_directory
|
|
523
553
|
: projectDirectory;
|
|
524
|
-
const
|
|
525
|
-
|
|
554
|
+
const result = await forkSessionToBtwThread({
|
|
555
|
+
sourceThread: thread,
|
|
556
|
+
projectDirectory,
|
|
557
|
+
sdkDirectory: btwSdkDir,
|
|
558
|
+
prompt: btwResult.prompt,
|
|
559
|
+
userId: message.author.id,
|
|
560
|
+
username: message.member?.displayName || message.author.displayName,
|
|
561
|
+
appId: currentAppId,
|
|
526
562
|
});
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
563
|
+
if (result instanceof Error) {
|
|
564
|
+
await message.reply({
|
|
565
|
+
content: result.message,
|
|
566
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
await message.reply({
|
|
571
|
+
content: `Session forked! Continue in ${result.thread.toString()}`,
|
|
572
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
530
573
|
});
|
|
531
|
-
await loadingReply.edit({ content: result });
|
|
532
574
|
return;
|
|
533
575
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
// Works like queue: just the word "btw" at the end after punctuation
|
|
537
|
-
// or newline. The whole message (minus the suffix) becomes the fork prompt.
|
|
538
|
-
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
539
|
-
? extractBtwSuffix(message.content || '')
|
|
540
|
-
: null;
|
|
541
|
-
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
542
|
-
const btwSdkDir = worktreeInfo?.status === 'ready' &&
|
|
543
|
-
worktreeInfo.workspace_directory
|
|
544
|
-
? worktreeInfo.workspace_directory
|
|
545
|
-
: projectDirectory;
|
|
546
|
-
const result = await forkSessionToBtwThread({
|
|
547
|
-
sourceThread: thread,
|
|
548
|
-
projectDirectory,
|
|
549
|
-
sdkDirectory: btwSdkDir,
|
|
550
|
-
prompt: btwResult.prompt,
|
|
551
|
-
userId: message.author.id,
|
|
552
|
-
username: message.member?.displayName || message.author.displayName,
|
|
553
|
-
appId: currentAppId,
|
|
576
|
+
const hasVoiceAttachment = message.attachments.some((attachment) => {
|
|
577
|
+
return isVoiceAttachment(attachment);
|
|
554
578
|
});
|
|
555
|
-
if (
|
|
579
|
+
if (!projectDirectory) {
|
|
580
|
+
discordLogger.log(`Cannot process message: no project directory for thread ${thread.id}`);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (isMissingReadableMessageContent(message)) {
|
|
556
584
|
await message.reply({
|
|
557
|
-
content:
|
|
585
|
+
content: MISSING_MESSAGE_CONTENT_REPLY,
|
|
558
586
|
flags: SILENT_MESSAGE_FLAGS,
|
|
559
587
|
});
|
|
560
588
|
return;
|
|
561
589
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
if (isMissingReadableMessageContent(message)) {
|
|
576
|
-
await message.reply({
|
|
577
|
-
content: MISSING_MESSAGE_CONTENT_REPLY,
|
|
578
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
590
|
+
const resolvedProjectDir = projectDirectory;
|
|
591
|
+
const sdkDir = worktreeInfo?.status === 'ready' &&
|
|
592
|
+
worktreeInfo.workspace_directory
|
|
593
|
+
? worktreeInfo.workspace_directory
|
|
594
|
+
: resolvedProjectDir;
|
|
595
|
+
const runtime = getOrCreateRuntime({
|
|
596
|
+
threadId: thread.id,
|
|
597
|
+
thread,
|
|
598
|
+
projectDirectory: resolvedProjectDir,
|
|
599
|
+
sdkDirectory: sdkDir,
|
|
600
|
+
channelId: parent?.id || undefined,
|
|
601
|
+
appId: currentAppId,
|
|
579
602
|
});
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
});
|
|
603
|
+
// Cancel interactive UI when a real user sends a message.
|
|
604
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
605
|
+
// the active run or dismiss pending UI.
|
|
606
|
+
const dismissSourceUi = async () => {
|
|
607
|
+
if (message.author.bot || isCliInjectedPrompt || isLeadingMentionToOtherUser)
|
|
608
|
+
return;
|
|
609
|
+
cancelPendingActionButtons(thread.id);
|
|
610
|
+
cancelHtmlActionsForThread(thread.id);
|
|
611
|
+
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
612
|
+
if (dismissedPermission) {
|
|
613
|
+
await runtime.abortActiveRunAndWait({
|
|
614
|
+
reason: 'user sent a new message while permission was pending',
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const dismissedQuestion = hasPendingQuestionForThread(thread.id);
|
|
618
|
+
if (dismissedQuestion) {
|
|
619
|
+
await cancelPendingQuestion(thread.id);
|
|
620
|
+
await runtime.abortActiveRunAndWait({
|
|
621
|
+
reason: 'user sent a new message while question was pending',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
void cancelPendingFileUpload(thread.id);
|
|
625
|
+
};
|
|
626
|
+
if (!hasVoiceAttachment) {
|
|
627
|
+
await dismissSourceUi();
|
|
606
628
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
629
|
+
// A sleep wake only becomes a turn if it can still claim its own row.
|
|
630
|
+
// The claim fails when the user cancelled the sleep while the wake was
|
|
631
|
+
// being posted, so the superseded wake is dropped instead of starting a
|
|
632
|
+
// turn the user already replaced. Cancellation for every other kind of
|
|
633
|
+
// message happens inside runtime.enqueueIncoming().
|
|
634
|
+
const isSleepWake = Boolean(promptMarker?.sleepWake);
|
|
635
|
+
if (isSleepWake) {
|
|
636
|
+
const claimedWake = promptMarker?.sleepId
|
|
637
|
+
? await consumeSessionSleepWake({ deliveryId: promptMarker.sleepId })
|
|
638
|
+
: false;
|
|
639
|
+
if (!claimedWake) {
|
|
640
|
+
discordLogger.log(`[SLEEP] ignoring superseded wake in thread ${thread.id}`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Expensive pre-processing (voice transcription, context fetch,
|
|
645
|
+
// attachment download) runs inside the runtime's serialized
|
|
646
|
+
// preprocess chain, preserving Discord arrival order without
|
|
647
|
+
// blocking SSE event handling in dispatchAction.
|
|
648
|
+
const enqueueResult = await runtime.enqueueIncoming({
|
|
649
|
+
prompt: '',
|
|
650
|
+
userId: cliInjectedUserId || message.author.id,
|
|
651
|
+
username: cliInjectedUsername ||
|
|
652
|
+
message.member?.displayName ||
|
|
653
|
+
message.author.displayName,
|
|
654
|
+
sourceMessageId: message.id,
|
|
655
|
+
sourceThreadId: thread.id,
|
|
656
|
+
appId: currentAppId,
|
|
657
|
+
agent: cliInjectedAgent,
|
|
658
|
+
model: cliInjectedModel,
|
|
659
|
+
permissions: cliInjectedPermissions,
|
|
660
|
+
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
661
|
+
parentSessionId: cliInjectedParentSessionId,
|
|
662
|
+
isSleepWake: isSleepWake || undefined,
|
|
663
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
664
|
+
sessionStartSource: sessionStartSource
|
|
665
|
+
? {
|
|
666
|
+
scheduleKind: sessionStartSource.scheduleKind,
|
|
667
|
+
scheduledTaskId: sessionStartSource.scheduledTaskId,
|
|
668
|
+
scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
|
|
669
|
+
}
|
|
670
|
+
: undefined,
|
|
671
|
+
preprocess: async () => {
|
|
672
|
+
const result = await preprocessExistingThreadMessage({
|
|
673
|
+
message,
|
|
674
|
+
thread,
|
|
675
|
+
projectDirectory: resolvedProjectDir,
|
|
676
|
+
channelId: parent?.id || undefined,
|
|
677
|
+
isCliInjected: isCliInjectedPrompt,
|
|
678
|
+
hasVoiceAttachment,
|
|
679
|
+
appId: currentAppId,
|
|
680
|
+
});
|
|
681
|
+
// Routing must finish before touching source UI. This chain is separate
|
|
682
|
+
// from dispatchAction, so abort waiting does not block session events.
|
|
683
|
+
if (hasVoiceAttachment && !result.skip) {
|
|
684
|
+
await dismissSourceUi();
|
|
685
|
+
}
|
|
686
|
+
return result;
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
// Notify when a voice message was queued instead of sent immediately
|
|
690
|
+
if (enqueueResult.queued && enqueueResult.position) {
|
|
691
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
613
692
|
}
|
|
614
|
-
void cancelPendingFileUpload(thread.id);
|
|
615
693
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
|
|
694
|
+
if (channel.type === ChannelType.GuildText) {
|
|
695
|
+
// `kimaki send` posts a starter message with a `start` embed marker,
|
|
696
|
+
// then creates the thread via REST. The ThreadCreate handler picks up
|
|
697
|
+
// that thread and starts the session. If we don't skip here, this
|
|
698
|
+
// handler races the CLI to call startThread() on the same message,
|
|
699
|
+
// causing DiscordAPIError[160004] "A thread has already been created
|
|
700
|
+
// for this message".
|
|
701
|
+
if (promptMarker?.start) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${channel.name} (${channel.id})`);
|
|
705
|
+
const channelConfig = await getChannelDirectory(channel.id);
|
|
706
|
+
if (!channelConfig) {
|
|
707
|
+
const botMentioned = Boolean(discordClient.user && message.mentions.has(discordClient.user.id));
|
|
708
|
+
if (botMentioned) {
|
|
709
|
+
// TODO: Consider creating/using a session for any text channel when Kimaki is
|
|
710
|
+
// explicitly @mentioned, so the bot can answer quick questions even before
|
|
711
|
+
// the channel is linked to a project.
|
|
712
|
+
await message.reply({
|
|
713
|
+
content: 'This channel is not connected to an OpenCode project.\nSend your message in a project channel, or use `/add-project` for an existing project, or `/create-new-project` to make a new one.',
|
|
714
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
640
717
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
718
|
+
voiceLogger.log(`[IGNORED] Channel #${channel.name} has no project directory configured`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const projectDirectory = channelConfig.directory;
|
|
722
|
+
// Note: Mention mode is checked early in the handler (before permission check)
|
|
723
|
+
// to avoid sending permission errors to users who just didn't @mention the bot.
|
|
724
|
+
discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`);
|
|
725
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
726
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
727
|
+
await message.reply({
|
|
728
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
729
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
651
730
|
});
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (enqueueResult.queued && enqueueResult.position) {
|
|
656
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
if (channel.type === ChannelType.GuildText) {
|
|
660
|
-
// `kimaki send` posts a starter message with a `start` embed marker,
|
|
661
|
-
// then creates the thread via REST. The ThreadCreate handler picks up
|
|
662
|
-
// that thread and starts the session. If we don't skip here, this
|
|
663
|
-
// handler races the CLI to call startThread() on the same message,
|
|
664
|
-
// causing DiscordAPIError[160004] "A thread has already been created
|
|
665
|
-
// for this message".
|
|
666
|
-
if (promptMarker?.start) {
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${channel.name} (${channel.id})`);
|
|
670
|
-
const channelConfig = await getChannelDirectory(channel.id);
|
|
671
|
-
if (!channelConfig) {
|
|
672
|
-
const botMentioned = Boolean(discordClient.user && message.mentions.has(discordClient.user.id));
|
|
673
|
-
if (botMentioned) {
|
|
674
|
-
// TODO: Consider creating/using a session for any text channel when Kimaki is
|
|
675
|
-
// explicitly @mentioned, so the bot can answer quick questions even before
|
|
676
|
-
// the channel is linked to a project.
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (isMissingReadableMessageContent(message)) {
|
|
677
734
|
await message.reply({
|
|
678
|
-
content:
|
|
735
|
+
content: MISSING_MESSAGE_CONTENT_REPLY,
|
|
679
736
|
flags: SILENT_MESSAGE_FLAGS,
|
|
680
737
|
});
|
|
681
738
|
return;
|
|
682
739
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
740
|
+
// ! prefix runs a shell command instead of starting a session
|
|
741
|
+
if (message.content?.startsWith('!')) {
|
|
742
|
+
const shellCmd = message.content.slice(1).trim();
|
|
743
|
+
if (shellCmd) {
|
|
744
|
+
threadIngressSlot?.release();
|
|
745
|
+
const loadingReply = await message.reply({
|
|
746
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
747
|
+
});
|
|
748
|
+
const result = await runShellCommand({
|
|
749
|
+
command: shellCmd,
|
|
750
|
+
directory: projectDirectory,
|
|
751
|
+
});
|
|
752
|
+
await loadingReply.edit({ content: result });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const hasVoice = message.attachments.some((attachment) => {
|
|
757
|
+
return isVoiceAttachment(attachment);
|
|
695
758
|
});
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
759
|
+
const baseThreadName = hasVoice
|
|
760
|
+
? 'Voice Message'
|
|
761
|
+
: stripMentions(message.content || '')
|
|
762
|
+
.replace(/\s+/g, ' ')
|
|
763
|
+
.trim() || 'kimaki thread';
|
|
764
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting).
|
|
765
|
+
// Only create worktrees from the configured project directory when that
|
|
766
|
+
// directory is itself the git root. If the user registered a non-git
|
|
767
|
+
// workspace folder under a larger repo, git would create the worktree
|
|
768
|
+
// from the parent repo and strand follow-up messages on failure.
|
|
769
|
+
const wantsWorktrees = useWorktrees || (await getChannelWorktreesEnabled(channel.id));
|
|
770
|
+
const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
|
|
771
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
772
|
+
discordLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
|
|
773
|
+
}
|
|
774
|
+
// Add worktree prefix if worktrees are enabled
|
|
775
|
+
const threadName = shouldUseWorktrees
|
|
776
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
777
|
+
: baseThreadName;
|
|
778
|
+
const thread = await message.startThread({
|
|
779
|
+
name: threadName.slice(0, 80),
|
|
780
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
781
|
+
reason: 'Start Claude session',
|
|
702
782
|
});
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
783
|
+
// Add user to thread so it appears in their sidebar
|
|
784
|
+
await thread.members.add(message.author.id);
|
|
785
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
786
|
+
// Create runtime immediately so follow-up messages queue naturally
|
|
787
|
+
// via the preprocess chain instead of being rejected with "please wait".
|
|
788
|
+
// When worktrees are enabled, the worktree promise runs concurrently
|
|
789
|
+
// and the first message's preprocess callback awaits it before resolving.
|
|
790
|
+
let worktreePromise;
|
|
791
|
+
if (shouldUseWorktrees) {
|
|
792
|
+
// Auto-derived from thread name -- compress long slugs so the
|
|
793
|
+
// folder path stays short and the agent doesn't reuse old worktrees.
|
|
794
|
+
const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
795
|
+
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
796
|
+
const worktreeStatusMessage = await thread
|
|
797
|
+
.send({
|
|
798
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
799
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
800
|
+
})
|
|
801
|
+
.catch(() => undefined);
|
|
802
|
+
worktreePromise = createWorktreeInBackground({
|
|
803
|
+
thread,
|
|
804
|
+
starterMessage: worktreeStatusMessage,
|
|
805
|
+
worktreeName,
|
|
806
|
+
projectDirectory,
|
|
807
|
+
rest: discordClient.rest,
|
|
715
808
|
});
|
|
716
|
-
await loadingReply.edit({ content: result });
|
|
717
|
-
return;
|
|
718
809
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
: stripMentions(message.content || '')
|
|
726
|
-
.replace(/\s+/g, ' ')
|
|
727
|
-
.trim() || 'kimaki thread';
|
|
728
|
-
// Check if worktrees should be enabled (CLI flag OR channel setting).
|
|
729
|
-
// Only create worktrees from the configured project directory when that
|
|
730
|
-
// directory is itself the git root. If the user registered a non-git
|
|
731
|
-
// workspace folder under a larger repo, git would create the worktree
|
|
732
|
-
// from the parent repo and strand follow-up messages on failure.
|
|
733
|
-
const wantsWorktrees = useWorktrees || (await getChannelWorktreesEnabled(channel.id));
|
|
734
|
-
const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
|
|
735
|
-
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
736
|
-
discordLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
|
|
737
|
-
}
|
|
738
|
-
// Add worktree prefix if worktrees are enabled
|
|
739
|
-
const threadName = shouldUseWorktrees
|
|
740
|
-
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
741
|
-
: baseThreadName;
|
|
742
|
-
const thread = await message.startThread({
|
|
743
|
-
name: threadName.slice(0, 80),
|
|
744
|
-
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
745
|
-
reason: 'Start Claude session',
|
|
746
|
-
});
|
|
747
|
-
// Add user to thread so it appears in their sidebar
|
|
748
|
-
await thread.members.add(message.author.id);
|
|
749
|
-
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
750
|
-
// Create runtime immediately so follow-up messages queue naturally
|
|
751
|
-
// via the preprocess chain instead of being rejected with "please wait".
|
|
752
|
-
// When worktrees are enabled, the worktree promise runs concurrently
|
|
753
|
-
// and the first message's preprocess callback awaits it before resolving.
|
|
754
|
-
let worktreePromise;
|
|
755
|
-
if (shouldUseWorktrees) {
|
|
756
|
-
// Auto-derived from thread name -- compress long slugs so the
|
|
757
|
-
// folder path stays short and the agent doesn't reuse old worktrees.
|
|
758
|
-
const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
759
|
-
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
760
|
-
const worktreeStatusMessage = await thread
|
|
761
|
-
.send({
|
|
762
|
-
content: worktreeCreatingMessage(worktreeName),
|
|
763
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
764
|
-
})
|
|
765
|
-
.catch(() => undefined);
|
|
766
|
-
worktreePromise = createWorktreeInBackground({
|
|
810
|
+
const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
|
|
811
|
+
if (worktreeResult instanceof Error)
|
|
812
|
+
return;
|
|
813
|
+
const sessionDirectory = worktreeResult;
|
|
814
|
+
const channelRuntime = getOrCreateRuntime({
|
|
815
|
+
threadId: thread.id,
|
|
767
816
|
thread,
|
|
768
|
-
starterMessage: worktreeStatusMessage,
|
|
769
|
-
worktreeName,
|
|
770
817
|
projectDirectory,
|
|
771
|
-
|
|
818
|
+
sdkDirectory: sessionDirectory,
|
|
819
|
+
channelId: channel.id,
|
|
820
|
+
appId: currentAppId,
|
|
821
|
+
});
|
|
822
|
+
await channelRuntime.enqueueIncoming({
|
|
823
|
+
prompt: '',
|
|
824
|
+
userId: message.author.id,
|
|
825
|
+
username: message.member?.displayName || message.author.displayName,
|
|
826
|
+
sourceMessageId: message.id,
|
|
827
|
+
sourceThreadId: thread.id,
|
|
828
|
+
appId: currentAppId,
|
|
829
|
+
preprocess: async () => {
|
|
830
|
+
return preprocessNewThreadMessage({
|
|
831
|
+
message,
|
|
832
|
+
thread,
|
|
833
|
+
projectDirectory: sessionDirectory,
|
|
834
|
+
hasVoiceAttachment: hasVoice,
|
|
835
|
+
appId: currentAppId,
|
|
836
|
+
});
|
|
837
|
+
},
|
|
772
838
|
});
|
|
773
839
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
const result = await worktreePromise;
|
|
779
|
-
if (result instanceof Error)
|
|
780
|
-
return projectDirectory;
|
|
781
|
-
return result;
|
|
782
|
-
})();
|
|
783
|
-
const channelRuntime = getOrCreateRuntime({
|
|
784
|
-
threadId: thread.id,
|
|
785
|
-
thread,
|
|
786
|
-
projectDirectory,
|
|
787
|
-
sdkDirectory: sessionDirectory,
|
|
788
|
-
channelId: channel.id,
|
|
789
|
-
appId: currentAppId,
|
|
790
|
-
});
|
|
791
|
-
await channelRuntime.enqueueIncoming({
|
|
792
|
-
prompt: '',
|
|
793
|
-
userId: message.author.id,
|
|
794
|
-
username: message.member?.displayName || message.author.displayName,
|
|
795
|
-
sourceMessageId: message.id,
|
|
796
|
-
sourceThreadId: thread.id,
|
|
797
|
-
appId: currentAppId,
|
|
798
|
-
preprocess: async () => {
|
|
799
|
-
return preprocessNewThreadMessage({
|
|
800
|
-
message,
|
|
801
|
-
thread,
|
|
802
|
-
projectDirectory: sessionDirectory,
|
|
803
|
-
hasVoiceAttachment: hasVoice,
|
|
804
|
-
appId: currentAppId,
|
|
805
|
-
});
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
// discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
catch (error) {
|
|
814
|
-
voiceLogger.error('Discord handler error:', error);
|
|
815
|
-
void notifyError(error, 'MessageCreate handler error');
|
|
816
|
-
try {
|
|
817
|
-
const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
|
|
818
|
-
await message.reply({
|
|
819
|
-
content: `Error: ${errMsg}`,
|
|
820
|
-
flags: NOTIFY_MESSAGE_FLAGS,
|
|
821
|
-
});
|
|
840
|
+
else {
|
|
841
|
+
// discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
842
|
+
}
|
|
822
843
|
}
|
|
823
|
-
catch (
|
|
824
|
-
voiceLogger.error('Discord handler error
|
|
844
|
+
catch (error) {
|
|
845
|
+
voiceLogger.error('Discord handler error:', error);
|
|
846
|
+
void notifyError(error, 'MessageCreate handler error');
|
|
847
|
+
try {
|
|
848
|
+
const errMsg = (error instanceof Error ? error.message : String(error)).slice(0, 1900);
|
|
849
|
+
await message.reply({
|
|
850
|
+
content: `Error: ${errMsg}`,
|
|
851
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
catch (sendError) {
|
|
855
|
+
voiceLogger.error('Discord handler error (fallback):', sendError instanceof Error ? sendError.message : String(sendError));
|
|
856
|
+
}
|
|
825
857
|
}
|
|
826
|
-
}
|
|
858
|
+
});
|
|
827
859
|
});
|
|
828
860
|
// Handle user message edits to update queued messages.
|
|
829
861
|
// When a user edits a message that is still waiting in kimaki's local queue,
|
|
@@ -869,11 +901,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
869
901
|
const displayName = message.member?.displayName ?? message.author.displayName;
|
|
870
902
|
if (result.removed) {
|
|
871
903
|
discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
872
|
-
await sendThreadMessage(channel,
|
|
904
|
+
await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** removed message from queue`);
|
|
873
905
|
}
|
|
874
906
|
else {
|
|
875
907
|
discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
|
|
876
|
-
await sendThreadMessage(channel,
|
|
908
|
+
await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** edited queued message`);
|
|
877
909
|
}
|
|
878
910
|
}
|
|
879
911
|
}
|
|
@@ -896,7 +928,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
896
928
|
if (!removed)
|
|
897
929
|
return;
|
|
898
930
|
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
899
|
-
await sendThreadMessage(channel,
|
|
931
|
+
await sendThreadMessage(channel, `${STATUS_PREFIX}**${removed.username}** removed message from queue`);
|
|
900
932
|
}
|
|
901
933
|
catch (error) {
|
|
902
934
|
discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
|
|
@@ -1055,18 +1087,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1055
1087
|
}
|
|
1056
1088
|
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
1057
1089
|
const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (!worktreePromise) {
|
|
1063
|
-
return projectDirectory;
|
|
1064
|
-
}
|
|
1065
|
-
const result = await worktreePromise;
|
|
1066
|
-
if (result instanceof Error)
|
|
1067
|
-
return projectDirectory;
|
|
1068
|
-
return result;
|
|
1069
|
-
})();
|
|
1090
|
+
const worktreeResult = worktreePromise ? await worktreePromise : undefined;
|
|
1091
|
+
if (worktreeResult instanceof Error)
|
|
1092
|
+
return;
|
|
1093
|
+
const sessionDirectory = cwdDirectory ?? worktreeResult ?? projectDirectory;
|
|
1070
1094
|
const runtime = getOrCreateRuntime({
|
|
1071
1095
|
threadId: thread.id,
|
|
1072
1096
|
thread,
|
|
@@ -1151,6 +1175,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1151
1175
|
await discordClient.login(token);
|
|
1152
1176
|
}
|
|
1153
1177
|
startHeapMonitor();
|
|
1178
|
+
startStdinCpuProfListener();
|
|
1154
1179
|
const stopTaskRunner = startTaskRunner({ token });
|
|
1155
1180
|
const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
|
|
1156
1181
|
// Prevent discord.js from permanently killing the REST token on 401.
|
|
@@ -1175,6 +1200,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1175
1200
|
}
|
|
1176
1201
|
global.shuttingDown = true;
|
|
1177
1202
|
try {
|
|
1203
|
+
stopStdinCpuProfListener();
|
|
1204
|
+
const flushed = await flushCpuProfiling();
|
|
1205
|
+
if (flushed instanceof Error) {
|
|
1206
|
+
discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
|
|
1207
|
+
}
|
|
1178
1208
|
await stopRuntimeIdleSweeper();
|
|
1179
1209
|
await stopTaskRunner();
|
|
1180
1210
|
await flushDebouncedProcessCallbacks().catch((error) => {
|
|
@@ -1244,13 +1274,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1244
1274
|
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1245
1275
|
});
|
|
1246
1276
|
});
|
|
1247
|
-
// Self-restart:
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
// for auto-restart support.
|
|
1252
|
-
// unreliable: process.exit(0) could kill the child before it started,
|
|
1253
|
-
// there was no backoff between restarts, and spawn failures were silent.
|
|
1277
|
+
// Self-restart: die so the bin.ts wrapper restarts us with exponential
|
|
1278
|
+
// backoff and crash-loop detection. process.exit() can hang joining native
|
|
1279
|
+
// worker threads after Discord gateway failures, so SIGKILL instead.
|
|
1280
|
+
// When running without the wrapper (e.g. `tsx src/cli.ts`), the process
|
|
1281
|
+
// just dies — use `tsx src/bin.ts` for auto-restart support.
|
|
1254
1282
|
let selfRestarting = false;
|
|
1255
1283
|
async function selfRestart(reason) {
|
|
1256
1284
|
if (selfRestarting) {
|
|
@@ -1259,6 +1287,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1259
1287
|
}
|
|
1260
1288
|
selfRestarting = true;
|
|
1261
1289
|
discordLogger.log(`Self-restarting (reason: ${reason})...`);
|
|
1290
|
+
setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
|
|
1262
1291
|
try {
|
|
1263
1292
|
await handleShutdown(reason, { skipExit: true });
|
|
1264
1293
|
}
|
|
@@ -1268,7 +1297,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1268
1297
|
if (!process.env.__KIMAKI_CHILD) {
|
|
1269
1298
|
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1270
1299
|
}
|
|
1271
|
-
process.
|
|
1300
|
+
process.kill(process.pid, 'SIGKILL');
|
|
1272
1301
|
}
|
|
1273
1302
|
process.on('SIGUSR2', () => {
|
|
1274
1303
|
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|