kimaki 0.20.0 → 0.21.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 +6 -4
- package/dist/cli-commands/project.js +72 -15
- package/dist/cli-commands/send.js +5 -0
- package/dist/cli-runner.js +29 -6
- package/dist/cli-runner.test.js +28 -1
- package/dist/commands/btw.js +3 -0
- package/dist/commands/tasks.js +97 -19
- package/dist/commands/user-command.js +47 -5
- package/dist/database.js +14 -0
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +39 -18
- package/dist/discord-utils.js +18 -1
- package/dist/interaction-handler.js +6 -15
- package/dist/message-formatting.js +8 -6
- package/dist/message-formatting.test.js +4 -4
- package/dist/queue-advanced-e2e-setup.js +22 -0
- package/dist/queue-delete-message.e2e.test.js +75 -0
- package/dist/schema.js +3 -0
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +49 -1
- package/dist/system-message.js +47 -60
- package/dist/system-message.test.js +65 -58
- package/dist/task-runner.js +18 -3
- package/dist/task-schedule.js +4 -0
- package/dist/thread-message-queue.e2e.test.js +4 -4
- package/dist/voice-message.e2e.test.js +1 -1
- package/package.json +12 -5
- package/skills/egaki/SKILL.md +32 -69
- package/skills/goke/SKILL.md +27 -20
- package/skills/holocron/SKILL.md +49 -8
- package/skills/new-skill/SKILL.md +87 -0
- package/skills/playwriter/SKILL.md +4 -4
- package/skills/sigillo/SKILL.md +35 -4
- package/skills/spiceflow/SKILL.md +2 -0
- package/src/agent-model.e2e.test.ts +10 -4
- package/src/cli-commands/project.ts +89 -15
- package/src/cli-commands/send.ts +8 -0
- package/src/cli-runner.test.ts +39 -1
- package/src/cli-runner.ts +34 -6
- package/src/commands/btw.ts +3 -0
- package/src/commands/tasks.ts +111 -19
- package/src/commands/user-command.ts +65 -4
- package/src/database.ts +24 -0
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +52 -19
- package/src/discord-utils.ts +24 -0
- package/src/interaction-handler.ts +7 -19
- package/src/message-formatting.test.ts +4 -4
- package/src/message-formatting.ts +9 -8
- package/src/queue-advanced-e2e-setup.ts +23 -0
- package/src/queue-delete-message.e2e.test.ts +102 -0
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/thread-runtime-state.ts +19 -0
- package/src/session-handler/thread-session-runtime.ts +75 -0
- package/src/system-message.test.ts +73 -58
- package/src/system-message.ts +60 -59
- package/src/task-runner.ts +34 -3
- package/src/task-schedule.ts +6 -0
- package/src/thread-message-queue.e2e.test.ts +4 -4
- package/src/voice-message.e2e.test.ts +1 -1
package/dist/discord-bot.js
CHANGED
|
@@ -261,9 +261,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
261
261
|
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
262
262
|
if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
263
263
|
discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
|
|
264
|
-
// Self-restart: cleanup then
|
|
265
|
-
//
|
|
266
|
-
// only restarts when the wrapper is the parent).
|
|
264
|
+
// Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
|
|
265
|
+
// restarts us. Without the wrapper this exits after logging a warning.
|
|
267
266
|
void selfRestart('gateway-reconnect-limit');
|
|
268
267
|
}
|
|
269
268
|
});
|
|
@@ -318,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
318
317
|
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
319
318
|
? promptMarker?.injectionGuardPatterns
|
|
320
319
|
: undefined;
|
|
320
|
+
const cliInjectedParentSessionId = isCliInjectedPrompt
|
|
321
|
+
? promptMarker?.parentSessionId
|
|
322
|
+
: undefined;
|
|
321
323
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
322
324
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
323
325
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
@@ -607,6 +609,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
607
609
|
model: cliInjectedModel,
|
|
608
610
|
permissions: cliInjectedPermissions,
|
|
609
611
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
612
|
+
parentSessionId: cliInjectedParentSessionId,
|
|
610
613
|
noReply: isLeadingMentionToOtherUser || undefined,
|
|
611
614
|
sessionStartSource: sessionStartSource
|
|
612
615
|
? {
|
|
@@ -628,7 +631,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
628
631
|
});
|
|
629
632
|
// Notify when a voice message was queued instead of sent immediately
|
|
630
633
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
631
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update
|
|
634
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
632
635
|
}
|
|
633
636
|
}
|
|
634
637
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -851,6 +854,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
851
854
|
discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
|
|
852
855
|
}
|
|
853
856
|
});
|
|
857
|
+
// Handle user message deletes to remove queued messages.
|
|
858
|
+
// Discord delete events do not include author/content, so attribution comes
|
|
859
|
+
// from the queued item captured at enqueue time.
|
|
860
|
+
discordClient.on(Events.MessageDelete, async (message) => {
|
|
861
|
+
try {
|
|
862
|
+
const channel = message.channel;
|
|
863
|
+
if (!channel.isThread())
|
|
864
|
+
return;
|
|
865
|
+
const runtime = getRuntime(channel.id);
|
|
866
|
+
if (!runtime)
|
|
867
|
+
return;
|
|
868
|
+
const removed = runtime.removeQueuedMessage(message.id);
|
|
869
|
+
if (!removed)
|
|
870
|
+
return;
|
|
871
|
+
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
872
|
+
await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
|
|
876
|
+
}
|
|
877
|
+
});
|
|
854
878
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
855
879
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
856
880
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -1018,6 +1042,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1018
1042
|
model: marker.model,
|
|
1019
1043
|
permissions: marker.permissions,
|
|
1020
1044
|
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
1045
|
+
parentSessionId: marker.parentSessionId,
|
|
1021
1046
|
mode: 'opencode',
|
|
1022
1047
|
sessionStartSource: botThreadStartSource
|
|
1023
1048
|
? {
|
|
@@ -1166,7 +1191,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1166
1191
|
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1167
1192
|
});
|
|
1168
1193
|
});
|
|
1169
|
-
// Self-restart:
|
|
1194
|
+
// Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
|
|
1195
|
+
// exponential backoff and crash-loop detection. When running without the
|
|
1196
|
+
// wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
|
|
1197
|
+
// sees the non-zero exit code — they should use `tsx src/bin.ts` instead
|
|
1198
|
+
// for auto-restart support. The previous detached-spawn fallback was
|
|
1199
|
+
// unreliable: process.exit(0) could kill the child before it started,
|
|
1200
|
+
// there was no backoff between restarts, and spawn failures were silent.
|
|
1170
1201
|
let selfRestarting = false;
|
|
1171
1202
|
async function selfRestart(reason) {
|
|
1172
1203
|
if (selfRestarting) {
|
|
@@ -1181,20 +1212,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1181
1212
|
catch (error) {
|
|
1182
1213
|
voiceLogger.error(`[${reason}] Error during shutdown:`, error);
|
|
1183
1214
|
}
|
|
1184
|
-
if (process.env.__KIMAKI_CHILD) {
|
|
1185
|
-
discordLogger.
|
|
1186
|
-
process.exit(1);
|
|
1215
|
+
if (!process.env.__KIMAKI_CHILD) {
|
|
1216
|
+
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1187
1217
|
}
|
|
1188
|
-
|
|
1189
|
-
const env = { ...process.env };
|
|
1190
|
-
delete env.__KIMAKI_CHILD;
|
|
1191
|
-
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
1192
|
-
stdio: 'inherit',
|
|
1193
|
-
detached: true,
|
|
1194
|
-
cwd: process.cwd(),
|
|
1195
|
-
env,
|
|
1196
|
-
}).unref();
|
|
1197
|
-
process.exit(0);
|
|
1218
|
+
process.exit(1);
|
|
1198
1219
|
}
|
|
1199
1220
|
process.on('SIGUSR2', () => {
|
|
1200
1221
|
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
package/dist/discord-utils.js
CHANGED
|
@@ -590,14 +590,31 @@ export async function resolveWorkingDirectory({ channel, }) {
|
|
|
590
590
|
workingDirectory,
|
|
591
591
|
};
|
|
592
592
|
}
|
|
593
|
+
// Discord upload size limits per server boost tier (bytes).
|
|
594
|
+
// Bots default to 25 MB; boosted servers raise the ceiling.
|
|
595
|
+
export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
|
|
593
596
|
/**
|
|
594
597
|
* Upload files to a Discord thread/channel in a single message.
|
|
595
598
|
* Sending all files in one message causes Discord to display images in a grid layout.
|
|
599
|
+
*
|
|
600
|
+
* Files are validated against the Discord upload size limit before reading them
|
|
601
|
+
* into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
|
|
602
|
+
* otherwise the conservative 25 MB bot default is used.
|
|
596
603
|
*/
|
|
597
|
-
export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
|
|
604
|
+
export async function uploadFilesToDiscord({ threadId, botToken, files, maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE, }) {
|
|
598
605
|
if (files.length === 0) {
|
|
599
606
|
return;
|
|
600
607
|
}
|
|
608
|
+
// Fail fast: check file sizes before reading anything into memory
|
|
609
|
+
const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE;
|
|
610
|
+
for (const file of files) {
|
|
611
|
+
const stat = fs.statSync(file);
|
|
612
|
+
if (stat.size > sizeLimit) {
|
|
613
|
+
const fileMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
614
|
+
const limitMB = (sizeLimit / 1024 / 1024).toFixed(0);
|
|
615
|
+
throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
601
618
|
// Build attachments array for all files
|
|
602
619
|
const attachments = files.map((file, index) => ({
|
|
603
620
|
id: index,
|
|
@@ -103,22 +103,13 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
103
103
|
// Multi-machine routing: only handle interactions for channels owned
|
|
104
104
|
// by this machine (have a project directory configured in local db).
|
|
105
105
|
// If not owned, silently return so the other machine handles it.
|
|
106
|
+
// Setup commands (create-new-project, add-project) bypass this check
|
|
107
|
+
// because they are designed to run from any channel — they create new
|
|
108
|
+
// project channels rather than requiring one to already exist.
|
|
109
|
+
const isSetupCommand = interaction.isChatInputCommand() &&
|
|
110
|
+
SETUP_COMMANDS.has(interaction.commandName);
|
|
106
111
|
const owned = await isInteractionOwnedByThisMachine(interaction);
|
|
107
|
-
if (!owned) {
|
|
108
|
-
// Setup commands get a helpful reply instead of silent ignore.
|
|
109
|
-
// Both machines may race to reply; one wins, the other silently
|
|
110
|
-
// fails (interaction tokens are single-use). The message is the
|
|
111
|
-
// same from either machine so the race is harmless.
|
|
112
|
-
if (interaction.isChatInputCommand() &&
|
|
113
|
-
SETUP_COMMANDS.has(interaction.commandName)) {
|
|
114
|
-
await interaction.reply({
|
|
115
|
-
content: 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
|
|
116
|
-
flags: MessageFlags.Ephemeral,
|
|
117
|
-
}).catch(() => {
|
|
118
|
-
// Another machine already responded, safe to ignore
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
112
|
+
if (!owned && !isSetupCommand) {
|
|
122
113
|
interactionLogger.log(`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`);
|
|
123
114
|
// Do not respond at all — consuming the interaction token would
|
|
124
115
|
// prevent the owning machine from responding (tokens are single-use).
|
|
@@ -272,9 +272,9 @@ const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
|
|
|
272
272
|
* 2. Long/multiline command with description → show description
|
|
273
273
|
* 3. Long/multiline command without description → truncate first line of command
|
|
274
274
|
*
|
|
275
|
-
* The description field was removed from the bash tool schema
|
|
276
|
-
*
|
|
277
|
-
*
|
|
275
|
+
* The description field was removed from the opencode v2 bash tool schema but
|
|
276
|
+
* kimaki's system prompt instructs models to always send it as an extra field.
|
|
277
|
+
* Case 3 is the fallback when a model omits it.
|
|
278
278
|
*/
|
|
279
279
|
export function formatBashToolTitle({ command, description, stateTitle, }) {
|
|
280
280
|
if (!command && !description && !stateTitle)
|
|
@@ -292,10 +292,12 @@ export function formatBashToolTitle({ command, description, stateTitle, }) {
|
|
|
292
292
|
return ` _${escapeInlineMarkdown(description)}_`;
|
|
293
293
|
}
|
|
294
294
|
if (firstMeaningfulLine.length > 0) {
|
|
295
|
-
const
|
|
296
|
-
|
|
295
|
+
const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH;
|
|
296
|
+
const base = needsTruncation
|
|
297
|
+
? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
|
|
297
298
|
: firstMeaningfulLine;
|
|
298
|
-
|
|
299
|
+
// Always add ellipsis when showing a partial command (multiline or length-truncated)
|
|
300
|
+
return ` _${escapeInlineMarkdown(base)}…_`;
|
|
299
301
|
}
|
|
300
302
|
if (stateTitle) {
|
|
301
303
|
return ` _${escapeInlineMarkdown(stateTitle)}_`;
|
|
@@ -47,7 +47,7 @@ describe('formatBashToolTitle', () => {
|
|
|
47
47
|
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
|
|
48
48
|
});
|
|
49
49
|
test('multiline command without description truncates to first line', () => {
|
|
50
|
-
expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo
|
|
50
|
+
expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello…_"`);
|
|
51
51
|
});
|
|
52
52
|
test('long single-line command is truncated with ellipsis', () => {
|
|
53
53
|
const longCommand = 'a'.repeat(150);
|
|
@@ -68,16 +68,16 @@ describe('formatBashToolTitle', () => {
|
|
|
68
68
|
expect(formatBashToolTitle({ command: '' })).toBe('');
|
|
69
69
|
});
|
|
70
70
|
test('leading blank line skipped, uses first meaningful line', () => {
|
|
71
|
-
expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm
|
|
71
|
+
expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
|
|
72
72
|
});
|
|
73
73
|
test('whitespace-only first line skipped', () => {
|
|
74
|
-
expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm
|
|
74
|
+
expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
|
|
75
75
|
});
|
|
76
76
|
test('no description field (new opencode) with multiline command', () => {
|
|
77
77
|
// This is the exact scenario that was broken: opencode removed `description`
|
|
78
78
|
// from the bash tool schema, so multiline commands rendered as just "┣ bash"
|
|
79
79
|
const command = 'git diff HEAD~1 --stat && git log --oneline -5';
|
|
80
|
-
expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -
|
|
80
|
+
expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`);
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
describe('formatTodoList', () => {
|
|
@@ -99,6 +99,27 @@ export function createDeterministicMatchers() {
|
|
|
99
99
|
partDelaysMs: [0, 0, 0, 3_000, 0],
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
|
+
const slowBusyMatcher = {
|
|
103
|
+
id: 'slow-busy-reply',
|
|
104
|
+
priority: 100,
|
|
105
|
+
when: {
|
|
106
|
+
latestUserTextIncludes: 'SLOW_BUSY_MARKER',
|
|
107
|
+
},
|
|
108
|
+
then: {
|
|
109
|
+
parts: [
|
|
110
|
+
{ type: 'stream-start', warnings: [] },
|
|
111
|
+
{ type: 'text-start', id: 'slow-busy' },
|
|
112
|
+
{ type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
|
|
113
|
+
{ type: 'text-end', id: 'slow-busy' },
|
|
114
|
+
{
|
|
115
|
+
type: 'finish',
|
|
116
|
+
finishReason: 'stop',
|
|
117
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
partDelaysMs: [0, 0, 0, 2_000, 0],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
102
123
|
const toolFollowupMatcher = {
|
|
103
124
|
id: 'tool-followup',
|
|
104
125
|
priority: 50,
|
|
@@ -704,6 +725,7 @@ export function createDeterministicMatchers() {
|
|
|
704
725
|
};
|
|
705
726
|
return [
|
|
706
727
|
slowAbortMatcher,
|
|
728
|
+
slowBusyMatcher,
|
|
707
729
|
typingRepulseMatcher,
|
|
708
730
|
pluginTimeoutSleepMatcher,
|
|
709
731
|
actionButtonClickFollowupMatcher,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// E2e tests for deleting queued Discord messages.
|
|
2
|
+
// Covers the MessageDelete path that removes pending local queue items before drain.
|
|
3
|
+
import { describe, test, expect } from 'vitest';
|
|
4
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
5
|
+
import { waitForBotMessageContaining, waitForFooterMessage, waitForThreadState, } from './test-utils.js';
|
|
6
|
+
const TEXT_CHANNEL_ID = '200000000000001071';
|
|
7
|
+
const e2eTest = describe;
|
|
8
|
+
e2eTest('queue delete message', () => {
|
|
9
|
+
const ctx = setupQueueAdvancedSuite({
|
|
10
|
+
channelId: TEXT_CHANNEL_ID,
|
|
11
|
+
channelName: 'queue-delete-message-e2e',
|
|
12
|
+
dirName: 'queue-delete-message-e2e',
|
|
13
|
+
username: 'queue-delete-tester',
|
|
14
|
+
});
|
|
15
|
+
test('deleting a queued Discord message removes it from queue', async () => {
|
|
16
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
17
|
+
content: 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup',
|
|
18
|
+
});
|
|
19
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
20
|
+
timeout: 4_000,
|
|
21
|
+
predicate: (t) => {
|
|
22
|
+
return t.name === 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup';
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const th = ctx.discord.thread(thread.id);
|
|
26
|
+
await th.waitForBotReply({ timeout: 4_000 });
|
|
27
|
+
const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
|
|
28
|
+
content: 'Reply with exactly: deleted-queue. queue',
|
|
29
|
+
});
|
|
30
|
+
await waitForThreadState({
|
|
31
|
+
threadId: thread.id,
|
|
32
|
+
predicate: (state) => {
|
|
33
|
+
return state.queueItems.some((item) => {
|
|
34
|
+
return item.sourceMessageId === queuedMsg.id;
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
timeout: 4_000,
|
|
38
|
+
description: 'queue has item to delete',
|
|
39
|
+
});
|
|
40
|
+
await th.user(TEST_USER_ID).deleteMessage({ messageId: queuedMsg.id });
|
|
41
|
+
await waitForThreadState({
|
|
42
|
+
threadId: thread.id,
|
|
43
|
+
predicate: (state) => {
|
|
44
|
+
return !state.queueItems.some((item) => {
|
|
45
|
+
return item.sourceMessageId === queuedMsg.id;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
timeout: 4_000,
|
|
49
|
+
description: 'queued item removed after Discord delete',
|
|
50
|
+
});
|
|
51
|
+
await waitForBotMessageContaining({
|
|
52
|
+
discord: ctx.discord,
|
|
53
|
+
threadId: thread.id,
|
|
54
|
+
text: 'removed message from queue',
|
|
55
|
+
timeout: 4_000,
|
|
56
|
+
});
|
|
57
|
+
await waitForFooterMessage({
|
|
58
|
+
discord: ctx.discord,
|
|
59
|
+
threadId: thread.id,
|
|
60
|
+
timeout: 8_000,
|
|
61
|
+
});
|
|
62
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
63
|
+
"--- from: user (queue-delete-tester)
|
|
64
|
+
SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup
|
|
65
|
+
--- from: assistant (TestBot)
|
|
66
|
+
*using deterministic-provider/deterministic-v2*
|
|
67
|
+
Queued at position 1. Edit or delete your message to update the queue
|
|
68
|
+
⬦ **queue-delete-tester** removed message from queue
|
|
69
|
+
⬥ slow-busy-reply
|
|
70
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
71
|
+
`);
|
|
72
|
+
const finalText = await th.text();
|
|
73
|
+
expect(finalText).not.toContain('» **queue-delete-tester:** Reply with exactly: deleted-queue');
|
|
74
|
+
}, 12_000);
|
|
75
|
+
});
|
package/dist/schema.js
CHANGED
|
@@ -23,6 +23,9 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
|
|
|
23
23
|
session_id: sqliteCore.text('session_id').notNull(),
|
|
24
24
|
source: sqliteCore.text('source', { enum: ['kimaki', 'external_poll'] }).notNull().default('kimaki'),
|
|
25
25
|
last_synced_name: sqliteCore.text('last_synced_name'),
|
|
26
|
+
// Parent OpenCode session that spawned this thread via kimaki send --parent-session.
|
|
27
|
+
// Survives bot restarts so child multi-turn system prompts keep the parent ID.
|
|
28
|
+
parent_session_id: sqliteCore.text('parent_session_id'),
|
|
26
29
|
created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
|
|
27
30
|
});
|
|
28
31
|
export const session_events = sqliteCore.sqliteTable('session_events', {
|
|
@@ -17,6 +17,7 @@ export function initialThreadState() {
|
|
|
17
17
|
sessionId: undefined,
|
|
18
18
|
sessionUsername: undefined,
|
|
19
19
|
sessionUserId: undefined,
|
|
20
|
+
parentSessionId: undefined,
|
|
20
21
|
queueItems: [],
|
|
21
22
|
sentPartIds: new Set(),
|
|
22
23
|
};
|
|
@@ -77,6 +78,14 @@ export function setSessionUserId(threadId, userId) {
|
|
|
77
78
|
return { ...t, sessionUserId: userId };
|
|
78
79
|
});
|
|
79
80
|
}
|
|
81
|
+
export function setParentSessionId(threadId, parentSessionId) {
|
|
82
|
+
updateThread(threadId, (t) => {
|
|
83
|
+
if (t.parentSessionId) {
|
|
84
|
+
return t;
|
|
85
|
+
}
|
|
86
|
+
return { ...t, parentSessionId };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
80
89
|
export function enqueueItem(threadId, item) {
|
|
81
90
|
updateThread(threadId, (t) => ({
|
|
82
91
|
...t,
|
|
@@ -17,7 +17,7 @@ import { registerEventListener, unregisterEventListener, } from './global-event-
|
|
|
17
17
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
18
18
|
import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
19
19
|
import { formatPart } from '../message-formatting.js';
|
|
20
|
-
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
|
|
20
|
+
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
|
|
21
21
|
import * as orm from 'drizzle-orm';
|
|
22
22
|
import * as schema from '../schema.js';
|
|
23
23
|
import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
|
|
@@ -2274,6 +2274,7 @@ export class ThreadSessionRuntime {
|
|
|
2274
2274
|
agents: availableAgents,
|
|
2275
2275
|
username: this.state?.sessionUsername || input.username,
|
|
2276
2276
|
userId: this.state?.sessionUserId || input.userId,
|
|
2277
|
+
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
2277
2278
|
}),
|
|
2278
2279
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
2279
2280
|
...(modelField ? { model: modelField } : {}),
|
|
@@ -2321,6 +2322,7 @@ export class ThreadSessionRuntime {
|
|
|
2321
2322
|
permissions: input.permissions,
|
|
2322
2323
|
permissionRules: input.permissionRules,
|
|
2323
2324
|
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
2325
|
+
parentSessionId: input.parentSessionId,
|
|
2324
2326
|
sourceMessageId: input.sourceMessageId,
|
|
2325
2327
|
sourceThreadId: input.sourceThreadId,
|
|
2326
2328
|
repliedMessage: input.repliedMessage,
|
|
@@ -2363,6 +2365,9 @@ export class ThreadSessionRuntime {
|
|
|
2363
2365
|
async enqueueIncoming(input) {
|
|
2364
2366
|
threadState.setSessionUsername(this.threadId, input.username);
|
|
2365
2367
|
threadState.setSessionUserId(this.threadId, input.userId);
|
|
2368
|
+
await this.ensureParentSessionId({
|
|
2369
|
+
parentSessionId: input.parentSessionId,
|
|
2370
|
+
});
|
|
2366
2371
|
// When a preprocessor is provided, we must resolve it inside
|
|
2367
2372
|
// dispatchAction before we know the final mode for routing.
|
|
2368
2373
|
if (input.preprocess) {
|
|
@@ -2383,6 +2388,33 @@ export class ThreadSessionRuntime {
|
|
|
2383
2388
|
}
|
|
2384
2389
|
return this.submitViaOpencodeQueue(input);
|
|
2385
2390
|
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Resolve parent session ID for child system prompts.
|
|
2393
|
+
* Prefer in-memory state, then SQLite, then the ingress marker.
|
|
2394
|
+
* Persist once so multi-turn child sessions keep the parent after restart.
|
|
2395
|
+
*/
|
|
2396
|
+
async ensureParentSessionId({ parentSessionId, }) {
|
|
2397
|
+
if (this.state?.parentSessionId) {
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
const storedParentSessionId = await getThreadParentSessionId(this.threadId);
|
|
2401
|
+
if (storedParentSessionId) {
|
|
2402
|
+
threadState.setParentSessionId(this.threadId, storedParentSessionId);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (!parentSessionId) {
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
threadState.setParentSessionId(this.threadId, parentSessionId);
|
|
2409
|
+
// Row may not exist yet on first ingress before ensureSession creates it.
|
|
2410
|
+
// Best-effort write; ensureSession path also persists after setThreadSession.
|
|
2411
|
+
await setThreadParentSessionId({
|
|
2412
|
+
threadId: this.threadId,
|
|
2413
|
+
parentSessionId,
|
|
2414
|
+
}).catch((error) => {
|
|
2415
|
+
logger.warn(`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2386
2418
|
/**
|
|
2387
2419
|
* Serialize the preprocess callback via a lightweight promise chain, then
|
|
2388
2420
|
* route the resolved input through the normal enqueue paths.
|
|
@@ -2586,6 +2618,10 @@ export class ThreadSessionRuntime {
|
|
|
2586
2618
|
return { found: true, removed: true };
|
|
2587
2619
|
return { found: true, removed: false };
|
|
2588
2620
|
}
|
|
2621
|
+
/** Remove a queued message identified by its Discord source message ID. */
|
|
2622
|
+
removeQueuedMessage(sourceMessageId) {
|
|
2623
|
+
return threadState.updateQueueItemBySourceMessageId(this.threadId, sourceMessageId, () => null);
|
|
2624
|
+
}
|
|
2589
2625
|
// ── Queue Drain ─────────────────────────────────────────────
|
|
2590
2626
|
/**
|
|
2591
2627
|
* Check if we can dispatch the next queued message. If so, dequeue and
|
|
@@ -2964,6 +3000,7 @@ export class ThreadSessionRuntime {
|
|
|
2964
3000
|
agents: earlyAvailableAgents,
|
|
2965
3001
|
username: this.state?.sessionUsername || input.username,
|
|
2966
3002
|
userId: this.state?.sessionUserId || input.userId,
|
|
3003
|
+
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
2967
3004
|
}),
|
|
2968
3005
|
model: earlyModelParam,
|
|
2969
3006
|
agent: earlyAgentPreference,
|
|
@@ -3103,6 +3140,17 @@ export class ThreadSessionRuntime {
|
|
|
3103
3140
|
// Store session in DB and thread state
|
|
3104
3141
|
await setThreadSession(this.thread.id, session.id);
|
|
3105
3142
|
threadState.setSessionId(this.threadId, session.id);
|
|
3143
|
+
// Parent may have been set on ingress before the thread_sessions row
|
|
3144
|
+
// existed; write it now that the row is guaranteed.
|
|
3145
|
+
const parentSessionId = this.state?.parentSessionId;
|
|
3146
|
+
if (parentSessionId) {
|
|
3147
|
+
await setThreadParentSessionId({
|
|
3148
|
+
threadId: this.thread.id,
|
|
3149
|
+
parentSessionId,
|
|
3150
|
+
}).catch((error) => {
|
|
3151
|
+
logger.warn(`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3152
|
+
});
|
|
3153
|
+
}
|
|
3106
3154
|
await this.hydrateSessionEventsFromDatabase({ sessionId: session.id });
|
|
3107
3155
|
// Store session start source for scheduled tasks
|
|
3108
3156
|
if (createdNewSession && sessionStartScheduleKind) {
|