kimaki 0.4.90 → 0.4.91
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 +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- package/src/worktrees.ts +152 -156
package/dist/discord-bot.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
2
|
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
3
|
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
-
import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, } from './database.js';
|
|
4
|
+
import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
|
|
5
5
|
import { stopOpencodeServer, } from './opencode.js';
|
|
6
6
|
import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
|
+
import { validateWorktreeDirectory, git } from './worktrees.js';
|
|
7
8
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
8
9
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
9
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
10
|
-
import
|
|
11
|
+
import YAML from 'yaml';
|
|
11
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
12
13
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
13
14
|
import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
@@ -19,7 +20,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
|
19
20
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
20
21
|
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
21
22
|
import {} from './session-handler/model-utils.js';
|
|
22
|
-
import { getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
|
|
23
|
+
import { getRuntime, getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
|
|
23
24
|
import { runShellCommand } from './commands/run-command.js';
|
|
24
25
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
25
26
|
import { getDiscordRestApiUrl } from './discord-urls.js';
|
|
@@ -35,15 +36,16 @@ export { getOpencodeSystemMessage } from './system-message.js';
|
|
|
35
36
|
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
|
|
36
37
|
import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
37
38
|
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
38
40
|
import * as errore from 'errore';
|
|
39
41
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
40
42
|
import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
|
|
41
43
|
import { startTaskRunner } from './task-runner.js';
|
|
42
|
-
import { setGlobalDispatcher, Agent } from 'undici';
|
|
43
44
|
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
44
45
|
// Each session's event.subscribe() holds a connection; without enough connections,
|
|
45
46
|
// regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
|
|
46
|
-
|
|
47
|
+
// undici is a transitive dep from discord.js — not listed in our package.json.
|
|
48
|
+
// Types are declared in src/undici.d.ts.
|
|
47
49
|
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
48
50
|
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
49
51
|
// Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
|
|
@@ -90,7 +92,7 @@ function parseEmbedFooterMarker({ footer, }) {
|
|
|
90
92
|
return undefined;
|
|
91
93
|
}
|
|
92
94
|
try {
|
|
93
|
-
const parsed =
|
|
95
|
+
const parsed = YAML.parse(footer);
|
|
94
96
|
if (!parsed || typeof parsed !== 'object') {
|
|
95
97
|
return undefined;
|
|
96
98
|
}
|
|
@@ -369,10 +371,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
369
371
|
projectDirectory = channelConfig.directory;
|
|
370
372
|
}
|
|
371
373
|
}
|
|
372
|
-
// Check if this thread is a worktree thread
|
|
374
|
+
// Check if this thread is a worktree thread.
|
|
375
|
+
// When the runtime exists in memory, pending worktrees are handled by
|
|
376
|
+
// the preprocess chain (messages queue behind the worktree promise).
|
|
377
|
+
// After a bot restart the runtime is gone, so we must reject messages
|
|
378
|
+
// for pending worktrees to avoid running in the base directory.
|
|
373
379
|
const worktreeInfo = await getThreadWorktree(thread.id);
|
|
374
380
|
if (worktreeInfo) {
|
|
375
|
-
if (worktreeInfo.status === 'pending') {
|
|
381
|
+
if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
|
|
376
382
|
await message.reply({
|
|
377
383
|
content: '⏳ Worktree is still being created. Please wait...',
|
|
378
384
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -401,9 +407,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
401
407
|
});
|
|
402
408
|
return;
|
|
403
409
|
}
|
|
404
|
-
// ! prefix runs a shell command instead of starting/continuing a session
|
|
405
|
-
// Use worktree directory if available, so commands run in the worktree cwd
|
|
406
|
-
|
|
410
|
+
// ! prefix runs a shell command instead of starting/continuing a session.
|
|
411
|
+
// Use worktree directory if available, so commands run in the worktree cwd.
|
|
412
|
+
// Skip shell commands while worktree is pending — they'd run in the base dir.
|
|
413
|
+
if (message.content?.startsWith('!') &&
|
|
414
|
+
projectDirectory &&
|
|
415
|
+
worktreeInfo?.status !== 'pending') {
|
|
407
416
|
const shellCmd = message.content.slice(1).trim();
|
|
408
417
|
if (shellCmd) {
|
|
409
418
|
const shellDir = worktreeInfo?.status === 'ready' &&
|
|
@@ -451,28 +460,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
451
460
|
reason: 'user sent a new message while permission was pending',
|
|
452
461
|
});
|
|
453
462
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// unblock OpenCode's question.waitForReply (without a reply the next
|
|
461
|
-
// promptAsync immediately fails with MessageAbortedError), then let
|
|
462
|
-
// the voice message flow through normal preprocessing — it gets
|
|
463
|
-
// transcribed and queued as the next user message after the model
|
|
464
|
-
// finishes responding to the empty answer.
|
|
465
|
-
if (message.content.trim().length > 0) {
|
|
466
|
-
const questionResult = await cancelPendingQuestion(thread.id, message.content);
|
|
467
|
-
if (questionResult === 'replied') {
|
|
468
|
-
void cancelPendingFileUpload(thread.id);
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
else if (hasPendingQuestionForThread(thread.id)) {
|
|
473
|
-
// Reply empty to unblock the question tool — no early return so
|
|
474
|
-
// the voice/image message continues through to enqueueIncoming.
|
|
475
|
-
await cancelPendingQuestion(thread.id, '');
|
|
463
|
+
const dismissedQuestion = hasPendingQuestionForThread(thread.id);
|
|
464
|
+
if (dismissedQuestion) {
|
|
465
|
+
await cancelPendingQuestion(thread.id);
|
|
466
|
+
await runtime.abortActiveRunAndWait({
|
|
467
|
+
reason: 'user sent a new message while question was pending',
|
|
468
|
+
});
|
|
476
469
|
}
|
|
477
470
|
void cancelPendingFileUpload(thread.id);
|
|
478
471
|
}
|
|
@@ -593,8 +586,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
593
586
|
// Add user to thread so it appears in their sidebar
|
|
594
587
|
await thread.members.add(message.author.id);
|
|
595
588
|
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
596
|
-
// Create
|
|
597
|
-
|
|
589
|
+
// Create runtime immediately so follow-up messages queue naturally
|
|
590
|
+
// via the preprocess chain instead of being rejected with "please wait".
|
|
591
|
+
// When worktrees are enabled, the worktree promise runs concurrently
|
|
592
|
+
// and the first message's preprocess callback awaits it before resolving.
|
|
593
|
+
let worktreePromise;
|
|
598
594
|
if (shouldUseWorktrees) {
|
|
599
595
|
const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
600
596
|
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
@@ -604,22 +600,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
604
600
|
flags: SILENT_MESSAGE_FLAGS,
|
|
605
601
|
})
|
|
606
602
|
.catch(() => undefined);
|
|
607
|
-
|
|
603
|
+
worktreePromise = createWorktreeInBackground({
|
|
608
604
|
thread,
|
|
609
605
|
starterMessage: worktreeStatusMessage,
|
|
610
606
|
worktreeName,
|
|
611
607
|
projectDirectory,
|
|
612
608
|
rest: discordClient.rest,
|
|
613
609
|
});
|
|
614
|
-
if (!(result instanceof Error)) {
|
|
615
|
-
sessionDirectory = result;
|
|
616
|
-
}
|
|
617
610
|
}
|
|
618
611
|
const channelRuntime = getOrCreateRuntime({
|
|
619
612
|
threadId: thread.id,
|
|
620
613
|
thread,
|
|
621
|
-
projectDirectory
|
|
622
|
-
sdkDirectory:
|
|
614
|
+
projectDirectory,
|
|
615
|
+
sdkDirectory: projectDirectory,
|
|
623
616
|
channelId: textChannel.id,
|
|
624
617
|
appId: currentAppId,
|
|
625
618
|
});
|
|
@@ -630,7 +623,20 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
630
623
|
sourceMessageId: message.id,
|
|
631
624
|
sourceThreadId: thread.id,
|
|
632
625
|
appId: currentAppId,
|
|
633
|
-
preprocess: () => {
|
|
626
|
+
preprocess: async () => {
|
|
627
|
+
// Wait for worktree creation + install before preprocessing.
|
|
628
|
+
// Follow-up messages queue behind this in the preprocess chain.
|
|
629
|
+
let sessionDirectory = projectDirectory;
|
|
630
|
+
if (worktreePromise) {
|
|
631
|
+
const result = await worktreePromise;
|
|
632
|
+
if (!(result instanceof Error)) {
|
|
633
|
+
sessionDirectory = result;
|
|
634
|
+
channelRuntime.handleDirectoryChanged({
|
|
635
|
+
oldDirectory: projectDirectory,
|
|
636
|
+
newDirectory: sessionDirectory,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
634
640
|
return preprocessNewThreadMessage({
|
|
635
641
|
message,
|
|
636
642
|
thread,
|
|
@@ -688,6 +694,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
688
694
|
if (!embedFooter) {
|
|
689
695
|
return;
|
|
690
696
|
}
|
|
697
|
+
// Only process markers from our own bot messages to prevent crafted embeds
|
|
698
|
+
if (starterMessage.author?.id !== discordClient.user?.id) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
691
701
|
const marker = parseEmbedFooterMarker({
|
|
692
702
|
footer: embedFooter,
|
|
693
703
|
});
|
|
@@ -722,11 +732,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
722
732
|
});
|
|
723
733
|
return;
|
|
724
734
|
}
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
735
|
+
// Start worktree creation concurrently if requested.
|
|
736
|
+
// The runtime is created immediately so follow-up messages queue
|
|
737
|
+
// naturally; the worktree promise is awaited inside enqueueIncoming.
|
|
738
|
+
let worktreePromise;
|
|
739
|
+
if (marker.worktree) {
|
|
730
740
|
discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`);
|
|
731
741
|
const worktreeStatusMessage = await thread
|
|
732
742
|
.send({
|
|
@@ -734,30 +744,72 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
734
744
|
flags: SILENT_MESSAGE_FLAGS,
|
|
735
745
|
})
|
|
736
746
|
.catch(() => undefined);
|
|
737
|
-
|
|
747
|
+
worktreePromise = createWorktreeInBackground({
|
|
738
748
|
thread,
|
|
739
749
|
starterMessage: worktreeStatusMessage,
|
|
740
750
|
worktreeName: marker.worktree,
|
|
741
751
|
projectDirectory,
|
|
742
752
|
rest: discordClient.rest,
|
|
743
753
|
});
|
|
744
|
-
|
|
745
|
-
|
|
754
|
+
}
|
|
755
|
+
// --cwd: reuse an existing worktree directory. Revalidate at bot-time
|
|
756
|
+
// (CLI validated at send-time but the path could become stale).
|
|
757
|
+
// Store in thread_worktrees as ready with origin=external so
|
|
758
|
+
// destructive actions (merge, delete) are gated.
|
|
759
|
+
// --cwd: if it matches projectDirectory, ignore silently (already the default).
|
|
760
|
+
// Otherwise revalidate as a git worktree and store with origin=external.
|
|
761
|
+
let cwdDirectory;
|
|
762
|
+
if (marker.cwd) {
|
|
763
|
+
const cwdResult = await validateWorktreeDirectory({
|
|
764
|
+
projectDirectory,
|
|
765
|
+
candidatePath: marker.cwd,
|
|
766
|
+
});
|
|
767
|
+
if (cwdResult instanceof Error) {
|
|
768
|
+
discordLogger.error(`[BOT_SESSION] --cwd validation failed: ${cwdResult.message}`);
|
|
769
|
+
await thread.send({
|
|
770
|
+
content: `✗ --cwd validation failed: ${cwdResult.message.slice(0, 1900)}`,
|
|
771
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// If cwd is the same as projectDirectory, skip worktree setup entirely
|
|
776
|
+
if (path.resolve(cwdResult) !== path.resolve(projectDirectory)) {
|
|
777
|
+
cwdDirectory = cwdResult;
|
|
778
|
+
// Resolve actual branch name instead of using directory basename
|
|
779
|
+
const branchResult = await git(cwdDirectory, 'symbolic-ref --short HEAD');
|
|
780
|
+
const cwdWorktreeName = branchResult instanceof Error
|
|
781
|
+
? path.basename(cwdDirectory)
|
|
782
|
+
: branchResult;
|
|
783
|
+
await createPendingWorktree({
|
|
784
|
+
threadId: thread.id,
|
|
785
|
+
worktreeName: cwdWorktreeName,
|
|
786
|
+
projectDirectory,
|
|
787
|
+
});
|
|
788
|
+
await setWorktreeReady({
|
|
789
|
+
threadId: thread.id,
|
|
790
|
+
worktreeDirectory: cwdDirectory,
|
|
791
|
+
});
|
|
792
|
+
// React with tree emoji to mark as worktree thread
|
|
793
|
+
await reactToThread({
|
|
794
|
+
rest: discordClient.rest,
|
|
795
|
+
threadId: thread.id,
|
|
796
|
+
channelId: parent.id,
|
|
797
|
+
emoji: '🌳',
|
|
798
|
+
});
|
|
746
799
|
}
|
|
747
|
-
|
|
748
|
-
})();
|
|
800
|
+
}
|
|
749
801
|
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
750
802
|
const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
|
|
751
803
|
const runtime = getOrCreateRuntime({
|
|
752
804
|
threadId: thread.id,
|
|
753
805
|
thread,
|
|
754
806
|
projectDirectory,
|
|
755
|
-
sdkDirectory:
|
|
807
|
+
sdkDirectory: projectDirectory,
|
|
756
808
|
channelId: parent.id,
|
|
757
809
|
appId: currentAppId,
|
|
758
810
|
});
|
|
759
811
|
await runtime.enqueueIncoming({
|
|
760
|
-
prompt,
|
|
812
|
+
prompt: '',
|
|
761
813
|
userId: marker.userId || '',
|
|
762
814
|
username: marker.username || 'bot',
|
|
763
815
|
appId: currentAppId,
|
|
@@ -772,6 +824,26 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
772
824
|
scheduledTaskId: botThreadStartSource.scheduledTaskId,
|
|
773
825
|
}
|
|
774
826
|
: undefined,
|
|
827
|
+
preprocess: async () => {
|
|
828
|
+
// Wait for worktree creation + install before starting session.
|
|
829
|
+
if (worktreePromise) {
|
|
830
|
+
const result = await worktreePromise;
|
|
831
|
+
if (!(result instanceof Error)) {
|
|
832
|
+
runtime.handleDirectoryChanged({
|
|
833
|
+
oldDirectory: projectDirectory,
|
|
834
|
+
newDirectory: result,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// --cwd: switch sdkDirectory to the existing worktree path
|
|
839
|
+
if (cwdDirectory) {
|
|
840
|
+
runtime.handleDirectoryChanged({
|
|
841
|
+
oldDirectory: projectDirectory,
|
|
842
|
+
newDirectory: cwdDirectory,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return { prompt, mode: 'opencode' };
|
|
846
|
+
},
|
|
775
847
|
});
|
|
776
848
|
}
|
|
777
849
|
catch (error) {
|
|
@@ -69,6 +69,25 @@ describe('splitMarkdownForDiscord', () => {
|
|
|
69
69
|
]
|
|
70
70
|
`);
|
|
71
71
|
});
|
|
72
|
+
test('task list code block does not duplicate checkbox marker when splitting', () => {
|
|
73
|
+
const content = `- [ ] Do thing
|
|
74
|
+
\`\`\`sh
|
|
75
|
+
echo hi
|
|
76
|
+
\`\`\`
|
|
77
|
+
`;
|
|
78
|
+
const result = splitMarkdownForDiscord({ content, maxLength: 80 });
|
|
79
|
+
expect(result.join('')).toContain('- [ ] Do thing\n');
|
|
80
|
+
expect(result.join('')).not.toContain('- [ ] [ ] Do thing');
|
|
81
|
+
expect(result).toMatchInlineSnapshot(`
|
|
82
|
+
[
|
|
83
|
+
"- [ ] Do thing
|
|
84
|
+
\`\`\`sh
|
|
85
|
+
echo hi
|
|
86
|
+
\`\`\`
|
|
87
|
+
",
|
|
88
|
+
]
|
|
89
|
+
`);
|
|
90
|
+
});
|
|
72
91
|
});
|
|
73
92
|
describe('hasKimakiBotPermission', () => {
|
|
74
93
|
test('allows API interaction member when kimaki role exists', () => {
|
package/dist/errors.js
CHANGED
|
@@ -125,11 +125,6 @@ export class NothingToMergeError extends createTaggedError({
|
|
|
125
125
|
message: 'No commits to merge -- branch is already up to date with $target',
|
|
126
126
|
}) {
|
|
127
127
|
}
|
|
128
|
-
export class SquashError extends createTaggedError({
|
|
129
|
-
name: 'SquashError',
|
|
130
|
-
message: 'Squash failed: $reason',
|
|
131
|
-
}) {
|
|
132
|
-
}
|
|
133
128
|
export class RebaseConflictError extends createTaggedError({
|
|
134
129
|
name: 'RebaseConflictError',
|
|
135
130
|
message: 'Rebase conflict while rebasing onto $target. Resolve conflicts, then run merge again.',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
|
|
4
|
+
const _execAsync = promisify(exec);
|
|
5
|
+
export function execAsync(command, options) {
|
|
6
|
+
const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS;
|
|
7
|
+
const execPromise = _execAsync(command, options);
|
|
8
|
+
let timer;
|
|
9
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => {
|
|
11
|
+
const pid = execPromise.child?.pid;
|
|
12
|
+
if (pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(-pid, 'SIGTERM');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
execPromise.child?.kill('SIGTERM');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`));
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
});
|
|
23
|
+
return Promise.race([execPromise, timeoutPromise]).finally(() => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -367,8 +367,7 @@ async function pulseTypingForBusySessions({ discordClient, statuses, }) {
|
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
|
-
|
|
371
|
-
// N*2 HTTP calls to 1 global list + per-active-directory status calls.
|
|
370
|
+
const EXTERNAL_SYNC_MAX_SESSIONS = 50;
|
|
372
371
|
async function pollExternalSessions({ discordClient, }) {
|
|
373
372
|
const trackedChannels = await listTrackedTextChannels();
|
|
374
373
|
const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
|
|
@@ -378,89 +377,52 @@ async function pollExternalSessions({ discordClient, }) {
|
|
|
378
377
|
if (directoryTargets.length === 0) {
|
|
379
378
|
return;
|
|
380
379
|
}
|
|
381
|
-
// Build a lookup: directory → { channelId, startMs }
|
|
382
|
-
const directoryMap = new Map();
|
|
383
380
|
for (const target of directoryTargets) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
381
|
+
const directory = target.directory;
|
|
382
|
+
const channelId = target.channelId;
|
|
383
|
+
const startMs = target.startMs;
|
|
384
|
+
const clientResult = await initializeOpencodeForDirectory(directory, {
|
|
385
|
+
channelId,
|
|
387
386
|
});
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const globalStartMs = Math.min(...directoryTargets.map((t) => {
|
|
391
|
-
return t.startMs;
|
|
392
|
-
}));
|
|
393
|
-
// Get one opencode client — try each existing directory until one succeeds
|
|
394
|
-
let client;
|
|
395
|
-
for (const target of directoryTargets) {
|
|
396
|
-
const result = await initializeOpencodeForDirectory(target.directory, {
|
|
397
|
-
channelId: target.channelId,
|
|
398
|
-
});
|
|
399
|
-
if (!(result instanceof Error)) {
|
|
400
|
-
client = result();
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
if (!client) {
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
// One global API call for all sessions across all directories.
|
|
408
|
-
// Results are sorted by most recently updated, so a fixed limit of 50
|
|
409
|
-
// is enough — we always get the most active sessions first.
|
|
410
|
-
const sessionsResponse = await client.experimental.session.list({
|
|
411
|
-
roots: true,
|
|
412
|
-
start: globalStartMs,
|
|
413
|
-
limit: 50,
|
|
414
|
-
}).catch((error) => {
|
|
415
|
-
return new Error('Failed to list global sessions', { cause: error });
|
|
416
|
-
});
|
|
417
|
-
if (sessionsResponse instanceof Error) {
|
|
418
|
-
logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
const allSessions = sessionsResponse.data || [];
|
|
422
|
-
// Group sessions by directory, filtering to tracked directories only
|
|
423
|
-
const sessionsByDirectory = new Map();
|
|
424
|
-
for (const session of allSessions) {
|
|
425
|
-
const target = directoryMap.get(session.directory);
|
|
426
|
-
if (!target) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
// Filter by per-directory startMs (time.updated or time.created)
|
|
430
|
-
if ((session.time.updated || session.time.created || 0) < target.startMs) {
|
|
387
|
+
if (clientResult instanceof Error) {
|
|
388
|
+
logger.warn(`[EXTERNAL_SYNC] Failed to initialize OpenCode for ${directory}: ${clientResult.message}`);
|
|
431
389
|
continue;
|
|
432
390
|
}
|
|
433
|
-
|
|
434
|
-
|
|
391
|
+
const client = clientResult();
|
|
392
|
+
const sessionsResponse = await client.session.list({
|
|
393
|
+
directory,
|
|
394
|
+
start: startMs,
|
|
395
|
+
limit: EXTERNAL_SYNC_MAX_SESSIONS,
|
|
396
|
+
}).catch((error) => {
|
|
397
|
+
return new Error(`Failed to list sessions for ${directory}`, {
|
|
398
|
+
cause: error,
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
if (sessionsResponse instanceof Error) {
|
|
402
|
+
logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
|
|
435
403
|
continue;
|
|
436
404
|
}
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
// Fetch session.status() only for directories that have sessions to sync.
|
|
442
|
-
// session.status() is instance-scoped (uses x-opencode-directory header),
|
|
443
|
-
// so we must call it per directory — but only for active ones, not all 30+.
|
|
444
|
-
const activeDirectories = [...sessionsByDirectory.keys()];
|
|
445
|
-
const statusResults = await Promise.all(activeDirectories.map(async (directory) => {
|
|
446
|
-
const res = await client.session.status({ directory }).catch(() => {
|
|
405
|
+
const statusesResponse = await client.session.status({
|
|
406
|
+
directory,
|
|
407
|
+
}).catch(() => {
|
|
447
408
|
return null;
|
|
448
409
|
});
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
410
|
+
if (statusesResponse?.data) {
|
|
411
|
+
await pulseTypingForBusySessions({
|
|
412
|
+
discordClient,
|
|
413
|
+
statuses: statusesResponse.data,
|
|
414
|
+
}).catch(() => { });
|
|
415
|
+
}
|
|
416
|
+
const sessions = (sessionsResponse.data || []).filter((session) => {
|
|
417
|
+
return !/^new session\s*-/i.test(session.title || '');
|
|
418
|
+
});
|
|
456
419
|
const sorted = sortSessionsByRecency(sessions);
|
|
457
|
-
logger.log(`[EXTERNAL_SYNC] ${directory}: ${sorted.length} sessions to sync`);
|
|
458
420
|
for (const session of sorted) {
|
|
459
421
|
await syncSessionToThread({
|
|
460
422
|
client,
|
|
461
423
|
discordClient,
|
|
462
424
|
directory,
|
|
463
|
-
channelId
|
|
425
|
+
channelId,
|
|
464
426
|
sessionId: session.id,
|
|
465
427
|
sessionTitle: session.title,
|
|
466
428
|
}).catch((error) => {
|
|
@@ -478,7 +440,6 @@ export function startExternalOpencodeSessionSync({ discordClient, }) {
|
|
|
478
440
|
if (externalSyncInterval) {
|
|
479
441
|
return;
|
|
480
442
|
}
|
|
481
|
-
logger.log(`[EXTERNAL_SYNC] started, polling every ${EXTERNAL_SYNC_INTERVAL_MS}ms`);
|
|
482
443
|
let polling = false;
|
|
483
444
|
const runPoll = async () => {
|
|
484
445
|
if (polling) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// On first run, migrates any existing forum-sync.json into the DB.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import
|
|
6
|
+
import YAML from 'yaml';
|
|
7
7
|
import { getDataDir } from '../config.js';
|
|
8
8
|
import { getForumSyncConfigs, upsertForumSyncConfig } from '../database.js';
|
|
9
9
|
import { createLogger } from '../logger.js';
|
|
@@ -29,7 +29,7 @@ async function migrateLegacyConfig({ appId }) {
|
|
|
29
29
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
30
30
|
let parsed;
|
|
31
31
|
try {
|
|
32
|
-
parsed =
|
|
32
|
+
parsed = YAML.parse(raw);
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
35
35
|
forumLogger.warn(`Failed to parse legacy ${LEGACY_CONFIG_FILE}, skipping migration`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Markdown parsing, serialization, and section formatting for forum sync.
|
|
2
2
|
// Handles frontmatter extraction, message section building, and
|
|
3
3
|
// conversion between Discord messages and markdown format.
|
|
4
|
-
import
|
|
4
|
+
import YAML from 'yaml';
|
|
5
5
|
import * as errore from 'errore';
|
|
6
6
|
import { ForumFrontmatterParseError, } from './types.js';
|
|
7
7
|
export function toStringArray({ value }) {
|
|
@@ -25,7 +25,7 @@ export function parseFrontmatter({ markdown, }) {
|
|
|
25
25
|
const rawFrontmatter = markdown.slice(4, end);
|
|
26
26
|
const body = markdown.slice(end + 5).trim();
|
|
27
27
|
const parsed = errore.try({
|
|
28
|
-
try: () =>
|
|
28
|
+
try: () => YAML.parse(rawFrontmatter),
|
|
29
29
|
catch: (cause) => new ForumFrontmatterParseError({ reason: 'yaml parse failed', cause }),
|
|
30
30
|
});
|
|
31
31
|
if (parsed instanceof Error || !parsed || typeof parsed !== 'object') {
|
|
@@ -34,13 +34,9 @@ export function parseFrontmatter({ markdown, }) {
|
|
|
34
34
|
return { frontmatter: parsed, body };
|
|
35
35
|
}
|
|
36
36
|
export function stringifyFrontmatter({ frontmatter, body, }) {
|
|
37
|
-
const yamlText =
|
|
38
|
-
.dump(frontmatter, {
|
|
37
|
+
const yamlText = YAML.stringify(frontmatter, null, {
|
|
39
38
|
lineWidth: 120,
|
|
40
|
-
|
|
41
|
-
sortKeys: false,
|
|
42
|
-
})
|
|
43
|
-
.trim();
|
|
39
|
+
}).trim();
|
|
44
40
|
return `---\n${yamlText}\n---\n\n${body.trim()}\n`;
|
|
45
41
|
}
|
|
46
42
|
export function splitSections({ body }) {
|
package/dist/hrana-server.js
CHANGED
|
@@ -227,7 +227,9 @@ export async function evictExistingInstance({ port }) {
|
|
|
227
227
|
try: () => {
|
|
228
228
|
process.kill(targetPid, 'SIGTERM');
|
|
229
229
|
},
|
|
230
|
-
catch: (e) =>
|
|
230
|
+
catch: (e) => new Error('Failed to send SIGTERM to existing kimaki process', {
|
|
231
|
+
cause: e,
|
|
232
|
+
}),
|
|
231
233
|
});
|
|
232
234
|
if (killResult instanceof Error) {
|
|
233
235
|
hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
|
|
@@ -243,12 +245,18 @@ export async function evictExistingInstance({ port }) {
|
|
|
243
245
|
if (secondProbe instanceof Error)
|
|
244
246
|
return;
|
|
245
247
|
hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`);
|
|
246
|
-
errore.try({
|
|
248
|
+
const forceKillResult = errore.try({
|
|
247
249
|
try: () => {
|
|
248
250
|
process.kill(targetPid, 'SIGKILL');
|
|
249
251
|
},
|
|
250
|
-
catch: (e) =>
|
|
252
|
+
catch: (e) => new Error('Failed to send SIGKILL to existing kimaki process', {
|
|
253
|
+
cause: e,
|
|
254
|
+
}),
|
|
251
255
|
});
|
|
256
|
+
if (forceKillResult instanceof Error) {
|
|
257
|
+
hranaLogger.log(`Failed to force-kill PID ${targetPid}: ${forceKillResult.message}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
252
260
|
await new Promise((resolve) => {
|
|
253
261
|
setTimeout(resolve, 1000);
|
|
254
262
|
});
|