kimaki 0.12.0 → 0.13.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/btw-prefix-detection.js +13 -15
- package/dist/btw-prefix-detection.test.js +60 -30
- package/dist/cli-runner.js +8 -2
- package/dist/cli.js +5 -0
- package/dist/commands/abort.js +1 -1
- package/dist/commands/model-variant.js +1 -1
- package/dist/commands/model.js +1 -1
- package/dist/commands/restart-opencode-server.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/upgrade.js +1 -2
- package/dist/discord-bot.js +55 -10
- package/dist/message-preprocessing.js +1 -1
- package/dist/opencode-interrupt-plugin.js +14 -2
- package/dist/opencode-interrupt-plugin.test.js +22 -3
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/session-handler/agent-utils.js +9 -9
- package/dist/session-handler/thread-runtime-state.js +29 -0
- package/dist/session-handler/thread-session-runtime.js +40 -6
- package/dist/store.js +1 -0
- package/dist/thread-message-queue.e2e.test.js +198 -1
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +432 -0
- package/src/btw-prefix-detection.test.ts +61 -30
- package/src/btw-prefix-detection.ts +15 -19
- package/src/cli-runner.ts +8 -2
- package/src/cli.ts +11 -0
- package/src/commands/abort.ts +1 -1
- package/src/commands/model-variant.ts +1 -1
- package/src/commands/model.ts +1 -1
- package/src/commands/restart-opencode-server.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/upgrade.ts +1 -2
- package/src/discord-bot.ts +65 -9
- package/src/message-preprocessing.ts +1 -1
- package/src/opencode-interrupt-plugin.test.ts +27 -3
- package/src/opencode-interrupt-plugin.ts +15 -3
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/session-handler/agent-utils.ts +11 -11
- package/src/session-handler/thread-runtime-state.ts +35 -0
- package/src/session-handler/thread-session-runtime.ts +56 -6
- package/src/store.ts +8 -0
- package/src/thread-message-queue.e2e.test.ts +227 -1
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// Detects
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Detects `. btw` suffix at the end of a Discord message, identical pattern
|
|
2
|
+
// to the queue suffix. When present the suffix is stripped and the remaining
|
|
3
|
+
// message is forked to a new btw thread via /btw.
|
|
4
|
+
//
|
|
5
|
+
// Supported forms:
|
|
6
|
+
// - punctuation + btw: ". btw", "! btw", ". btw.", "!btw."
|
|
7
|
+
// - btw as its own final line: "text\nbtw"
|
|
8
|
+
// Non-matches: "btw fix this" (start only), "hello btw" (no punctuation)
|
|
9
|
+
const BTW_SUFFIX_RE = /(?:[.!?,;:])\s*btw\.?\s*$|\n\s*btw\.?\s*$/i;
|
|
10
|
+
export function extractBtwSuffix(content) {
|
|
11
|
+
if (!BTW_SUFFIX_RE.test(content)) {
|
|
12
|
+
return { prompt: content, forceBtw: false };
|
|
6
13
|
}
|
|
7
|
-
|
|
8
|
-
const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
|
|
9
|
-
if (!match) {
|
|
10
|
-
return null;
|
|
11
|
-
}
|
|
12
|
-
const prompt = match[1]?.trim();
|
|
13
|
-
if (!prompt) {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
return { prompt };
|
|
14
|
+
return { prompt: content.replace(BTW_SUFFIX_RE, '').trimEnd(), forceBtw: true };
|
|
17
15
|
}
|
|
@@ -1,63 +1,93 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
describe('
|
|
4
|
-
test('matches
|
|
5
|
-
expect(
|
|
2
|
+
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
3
|
+
describe('extractBtwSuffix', () => {
|
|
4
|
+
test('matches after period', () => {
|
|
5
|
+
expect(extractBtwSuffix('fix the bug. btw')).toMatchInlineSnapshot(`
|
|
6
6
|
{
|
|
7
|
-
"
|
|
7
|
+
"forceBtw": true,
|
|
8
|
+
"prompt": "fix the bug",
|
|
8
9
|
}
|
|
9
10
|
`);
|
|
10
11
|
});
|
|
11
|
-
test('matches
|
|
12
|
-
expect(
|
|
12
|
+
test('matches after exclamation', () => {
|
|
13
|
+
expect(extractBtwSuffix('done! btw')).toMatchInlineSnapshot(`
|
|
13
14
|
{
|
|
14
|
-
"
|
|
15
|
+
"forceBtw": true,
|
|
16
|
+
"prompt": "done",
|
|
15
17
|
}
|
|
16
18
|
`);
|
|
17
19
|
});
|
|
18
|
-
test('
|
|
19
|
-
expect(
|
|
20
|
+
test('matches after comma', () => {
|
|
21
|
+
expect(extractBtwSuffix('sure, btw')).toMatchInlineSnapshot(`
|
|
20
22
|
{
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
+
"forceBtw": true,
|
|
24
|
+
"prompt": "sure",
|
|
23
25
|
}
|
|
24
26
|
`);
|
|
25
27
|
});
|
|
26
|
-
test('matches
|
|
27
|
-
expect(
|
|
28
|
+
test('matches after newline', () => {
|
|
29
|
+
expect(extractBtwSuffix('fix the bug\nbtw')).toMatchInlineSnapshot(`
|
|
28
30
|
{
|
|
29
|
-
"
|
|
31
|
+
"forceBtw": true,
|
|
32
|
+
"prompt": "fix the bug",
|
|
30
33
|
}
|
|
31
34
|
`);
|
|
32
35
|
});
|
|
33
|
-
test('matches
|
|
34
|
-
expect(
|
|
36
|
+
test('matches with trailing dot', () => {
|
|
37
|
+
expect(extractBtwSuffix('fix the bug. btw.')).toMatchInlineSnapshot(`
|
|
35
38
|
{
|
|
36
|
-
"
|
|
39
|
+
"forceBtw": true,
|
|
40
|
+
"prompt": "fix the bug",
|
|
37
41
|
}
|
|
38
42
|
`);
|
|
39
43
|
});
|
|
40
|
-
test('
|
|
41
|
-
expect(
|
|
44
|
+
test('case insensitive', () => {
|
|
45
|
+
expect(extractBtwSuffix('done. BTW')).toMatchInlineSnapshot(`
|
|
42
46
|
{
|
|
43
|
-
"
|
|
47
|
+
"forceBtw": true,
|
|
48
|
+
"prompt": "done",
|
|
44
49
|
}
|
|
45
50
|
`);
|
|
46
51
|
});
|
|
47
|
-
test('
|
|
48
|
-
expect(
|
|
52
|
+
test('no space between punctuation and btw', () => {
|
|
53
|
+
expect(extractBtwSuffix('done.btw')).toMatchInlineSnapshot(`
|
|
49
54
|
{
|
|
50
|
-
"
|
|
55
|
+
"forceBtw": true,
|
|
56
|
+
"prompt": "done",
|
|
51
57
|
}
|
|
52
58
|
`);
|
|
53
59
|
});
|
|
54
|
-
test('does not match
|
|
55
|
-
expect(
|
|
60
|
+
test('does not match at start of message', () => {
|
|
61
|
+
expect(extractBtwSuffix('btw fix this')).toMatchInlineSnapshot(`
|
|
62
|
+
{
|
|
63
|
+
"forceBtw": false,
|
|
64
|
+
"prompt": "btw fix this",
|
|
65
|
+
}
|
|
66
|
+
`);
|
|
56
67
|
});
|
|
57
|
-
test('does not match mid-message', () => {
|
|
58
|
-
expect(
|
|
68
|
+
test('does not match mid-message without punctuation', () => {
|
|
69
|
+
expect(extractBtwSuffix('hello btw')).toMatchInlineSnapshot(`
|
|
70
|
+
{
|
|
71
|
+
"forceBtw": false,
|
|
72
|
+
"prompt": "hello btw",
|
|
73
|
+
}
|
|
74
|
+
`);
|
|
59
75
|
});
|
|
60
|
-
test('does not match empty
|
|
61
|
-
expect(
|
|
76
|
+
test('does not match empty content', () => {
|
|
77
|
+
expect(extractBtwSuffix('')).toMatchInlineSnapshot(`
|
|
78
|
+
{
|
|
79
|
+
"forceBtw": false,
|
|
80
|
+
"prompt": "",
|
|
81
|
+
}
|
|
82
|
+
`);
|
|
83
|
+
});
|
|
84
|
+
test('multiline message with btw at end', () => {
|
|
85
|
+
expect(extractBtwSuffix('first line\nsecond line. btw')).toMatchInlineSnapshot(`
|
|
86
|
+
{
|
|
87
|
+
"forceBtw": true,
|
|
88
|
+
"prompt": "first line
|
|
89
|
+
second line",
|
|
90
|
+
}
|
|
91
|
+
`);
|
|
62
92
|
});
|
|
63
93
|
});
|
package/dist/cli-runner.js
CHANGED
|
@@ -111,7 +111,11 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
111
111
|
const discordMaxLength = 2000;
|
|
112
112
|
if (prompt.length <= discordMaxLength) {
|
|
113
113
|
return (await rest.post(Routes.channelMessages(channelId), {
|
|
114
|
-
body: {
|
|
114
|
+
body: {
|
|
115
|
+
content: prompt,
|
|
116
|
+
embeds,
|
|
117
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
118
|
+
},
|
|
115
119
|
}));
|
|
116
120
|
}
|
|
117
121
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
@@ -158,6 +162,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
158
162
|
content: summaryContent,
|
|
159
163
|
attachments: [{ id: 0, filename: 'prompt.md' }],
|
|
160
164
|
embeds,
|
|
165
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
161
166
|
}));
|
|
162
167
|
const buffer = fs.readFileSync(tmpFile);
|
|
163
168
|
formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
|
|
@@ -509,6 +514,7 @@ export async function ensureCommandAvailable({ name, envPathKey, installUnix, in
|
|
|
509
514
|
}
|
|
510
515
|
// Run opencode upgrade in the background so the user always has the latest version.
|
|
511
516
|
// Spawn caffeinate on macOS to prevent system sleep while bot is running.
|
|
517
|
+
// Uses -s to also prevent sleep on lid close (AC power only, not battery).
|
|
512
518
|
// Uses -w to watch the parent PID so caffeinate self-terminates if kimaki
|
|
513
519
|
// exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
|
|
514
520
|
export function startCaffeinate() {
|
|
@@ -516,7 +522,7 @@ export function startCaffeinate() {
|
|
|
516
522
|
return;
|
|
517
523
|
}
|
|
518
524
|
try {
|
|
519
|
-
const proc = spawn('caffeinate', ['-
|
|
525
|
+
const proc = spawn('caffeinate', ['-s', '-w', String(process.pid)], {
|
|
520
526
|
stdio: 'ignore',
|
|
521
527
|
detached: false,
|
|
522
528
|
});
|
package/dist/cli.js
CHANGED
|
@@ -44,6 +44,10 @@ cli
|
|
|
44
44
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
45
45
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
46
46
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
47
|
+
.option('--allow-mention <type>', z
|
|
48
|
+
.array(z.enum(['users', 'roles', 'everyone']))
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('Which mention types the bot can trigger (users, roles, everyone). Repeatable. Default: users only.'))
|
|
47
51
|
.option('--enable-skill <name>', z
|
|
48
52
|
.array(z.string())
|
|
49
53
|
.optional()
|
|
@@ -140,6 +144,7 @@ cli
|
|
|
140
144
|
...(options.disableSync && { syncEnabled: false }),
|
|
141
145
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
142
146
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
147
|
+
...(options.allowMention && { allowedMentions: options.allowMention }),
|
|
143
148
|
});
|
|
144
149
|
if (enabledSkills.length > 0) {
|
|
145
150
|
cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
|
package/dist/commands/abort.js
CHANGED
|
@@ -292,7 +292,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
|
|
|
292
292
|
async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
|
|
293
293
|
const modelId = context.modelId;
|
|
294
294
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
295
|
-
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
295
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
296
296
|
try {
|
|
297
297
|
if (scope === 'session') {
|
|
298
298
|
if (!context.sessionId) {
|
package/dist/commands/model.js
CHANGED
|
@@ -728,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
728
728
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
729
729
|
const variant = context.selectedVariant ?? null;
|
|
730
730
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
731
|
-
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
731
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
732
732
|
try {
|
|
733
733
|
if (selectedScope === 'session') {
|
|
734
734
|
if (!context.sessionId) {
|
|
@@ -46,7 +46,7 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
|
|
|
46
46
|
}
|
|
47
47
|
const { projectDirectory } = resolved;
|
|
48
48
|
// Defer reply since restart may take a moment
|
|
49
|
-
await command.deferReply(
|
|
49
|
+
await command.deferReply();
|
|
50
50
|
// Dispose all runtimes for this directory/channel scope.
|
|
51
51
|
// disposeRuntimesForDirectory aborts active runs, kills listeners, and
|
|
52
52
|
// removes runtimes from the registry. Scoped by channelId so runtimes
|
|
@@ -58,7 +58,7 @@ export async function handleUndoCommand({ command, }) {
|
|
|
58
58
|
});
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
-
await command.deferReply(
|
|
61
|
+
await command.deferReply();
|
|
62
62
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
63
63
|
if (getClient instanceof Error) {
|
|
64
64
|
await command.editReply(`Failed to undo: ${getClient.message}`);
|
|
@@ -209,7 +209,7 @@ export async function handleRedoCommand({ command, }) {
|
|
|
209
209
|
});
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
|
-
await command.deferReply(
|
|
212
|
+
await command.deferReply();
|
|
213
213
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
214
214
|
if (getClient instanceof Error) {
|
|
215
215
|
await command.editReply(`Failed to redo: ${getClient.message}`);
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// /upgrade-and-restart command - Upgrade kimaki to the latest version and restart the bot.
|
|
2
2
|
// Checks npm for a newer version, installs it globally, then spawns a new kimaki process.
|
|
3
3
|
// The new process kills the old one on startup (kimaki's single-instance lock).
|
|
4
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
4
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
6
5
|
import { getCurrentVersion, upgrade } from '../upgrade.js';
|
|
7
6
|
import { spawn } from 'node:child_process';
|
|
8
7
|
const logger = createLogger(LogPrefix.CLI);
|
|
9
8
|
export async function handleUpgradeAndRestartCommand({ command, }) {
|
|
10
|
-
await command.deferReply(
|
|
9
|
+
await command.deferReply();
|
|
11
10
|
logger.log('[UPGRADE] /upgrade-and-restart triggered');
|
|
12
11
|
try {
|
|
13
12
|
const currentVersion = getCurrentVersion();
|
package/dist/discord-bot.js
CHANGED
|
@@ -10,10 +10,10 @@ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage
|
|
|
10
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
13
|
-
import {
|
|
13
|
+
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
14
14
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
15
15
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
16
|
-
import { getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
16
|
+
import { extractQueueSuffix, getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
17
17
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
18
18
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
19
19
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
@@ -30,6 +30,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
|
|
|
30
30
|
import { notifyError } from './sentry.js';
|
|
31
31
|
import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
|
|
32
32
|
import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
|
|
33
|
+
import { store } from './store.js';
|
|
33
34
|
import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
|
|
34
35
|
export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
|
|
35
36
|
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
@@ -144,6 +145,7 @@ export async function createDiscordClient() {
|
|
|
144
145
|
// Read REST API URL lazily so gateway mode can set store.discordBaseUrl
|
|
145
146
|
// after module import but before client creation.
|
|
146
147
|
const restApiUrl = getDiscordRestApiUrl();
|
|
148
|
+
const { allowedMentions } = store.getState();
|
|
147
149
|
return new Client({
|
|
148
150
|
intents: [
|
|
149
151
|
GatewayIntentBits.Guilds,
|
|
@@ -158,6 +160,7 @@ export async function createDiscordClient() {
|
|
|
158
160
|
Partials.ThreadMember,
|
|
159
161
|
],
|
|
160
162
|
rest: { api: restApiUrl },
|
|
163
|
+
allowedMentions: { parse: allowedMentions },
|
|
161
164
|
});
|
|
162
165
|
}
|
|
163
166
|
export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
|
|
@@ -455,18 +458,17 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
455
458
|
return;
|
|
456
459
|
}
|
|
457
460
|
}
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
? extractBtwPrefix(message.content || '')
|
|
461
|
+
// `. btw` suffix mirrors /btw for fast side-question forks.
|
|
462
|
+
// Works like queue: just the word "btw" at the end after punctuation
|
|
463
|
+
// or newline. The whole message (minus the suffix) becomes the fork prompt.
|
|
464
|
+
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
465
|
+
? extractBtwSuffix(message.content || '')
|
|
464
466
|
: null;
|
|
465
|
-
if (
|
|
467
|
+
if (btwResult?.forceBtw && projectDirectory) {
|
|
466
468
|
const result = await forkSessionToBtwThread({
|
|
467
469
|
sourceThread: thread,
|
|
468
470
|
projectDirectory,
|
|
469
|
-
prompt:
|
|
471
|
+
prompt: btwResult.prompt,
|
|
470
472
|
userId: message.author.id,
|
|
471
473
|
username: message.member?.displayName || message.author.displayName,
|
|
472
474
|
appId: currentAppId,
|
|
@@ -743,6 +745,49 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
743
745
|
}
|
|
744
746
|
}
|
|
745
747
|
});
|
|
748
|
+
// Handle user message edits to update queued messages.
|
|
749
|
+
// When a user edits a message that is still waiting in kimaki's local queue,
|
|
750
|
+
// the queue item is updated with the new content. If the edit removes the
|
|
751
|
+
// queue suffix, the item is removed from the queue.
|
|
752
|
+
discordClient.on(Events.MessageUpdate, async (_oldMessage, newMessage) => {
|
|
753
|
+
try {
|
|
754
|
+
// Fetch full message if partial (cache miss). Needed for mentions
|
|
755
|
+
// and content to be fully resolved.
|
|
756
|
+
const message = newMessage.partial
|
|
757
|
+
? await newMessage.fetch().catch(() => null)
|
|
758
|
+
: newMessage;
|
|
759
|
+
if (!message)
|
|
760
|
+
return;
|
|
761
|
+
if (message.author.bot)
|
|
762
|
+
return;
|
|
763
|
+
if (!message.content)
|
|
764
|
+
return;
|
|
765
|
+
const channel = message.channel;
|
|
766
|
+
const isThread = [
|
|
767
|
+
ChannelType.PublicThread,
|
|
768
|
+
ChannelType.PrivateThread,
|
|
769
|
+
ChannelType.AnnouncementThread,
|
|
770
|
+
].includes(channel.type);
|
|
771
|
+
if (!isThread)
|
|
772
|
+
return;
|
|
773
|
+
const runtime = getRuntime(channel.id);
|
|
774
|
+
if (!runtime)
|
|
775
|
+
return;
|
|
776
|
+
// Use resolveMentions to match initial preprocessing and preserve
|
|
777
|
+
// newlines (stripMentions collapses them, breaking final-line queue
|
|
778
|
+
// suffix detection).
|
|
779
|
+
const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
|
|
780
|
+
// If the edit removed the queue suffix, remove the item from the queue.
|
|
781
|
+
// If the suffix is still present, update the prompt.
|
|
782
|
+
const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
|
|
783
|
+
if (result.found) {
|
|
784
|
+
discordLogger.log(`[MESSAGE_EDIT] ${result.removed ? 'Removed' : 'Updated'} queued message ${message.id} in thread ${channel.id}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
|
|
789
|
+
}
|
|
790
|
+
});
|
|
746
791
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
747
792
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
748
793
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -45,7 +45,7 @@ async function fetchAvailableAgents(getClient, directory) {
|
|
|
45
45
|
// kimaki's local queue (same as /queue command).
|
|
46
46
|
const QUEUE_SUFFIX_RE = /(?:[.!?,;:]|^)\s*queue\.?\s*$|\n\s*queue\.?\s*$/i;
|
|
47
47
|
const REPLIED_MESSAGE_TEXT_LIMIT = 1_000;
|
|
48
|
-
function extractQueueSuffix(prompt) {
|
|
48
|
+
export function extractQueueSuffix(prompt) {
|
|
49
49
|
if (!QUEUE_SUFFIX_RE.test(prompt)) {
|
|
50
50
|
return { prompt, forceQueue: false };
|
|
51
51
|
}
|
|
@@ -56,6 +56,7 @@ function toPromptParts(parts) {
|
|
|
56
56
|
}, []);
|
|
57
57
|
}
|
|
58
58
|
const DEFAULT_INTERRUPT_STEP_TIMEOUT_MS = 3_000;
|
|
59
|
+
const POST_ABORT_IDLE_GRACE_MS = 250;
|
|
59
60
|
function getInterruptStepTimeoutMsFromEnv() {
|
|
60
61
|
const raw = process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'];
|
|
61
62
|
if (!raw) {
|
|
@@ -240,7 +241,7 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
240
241
|
},
|
|
241
242
|
timeoutMs: 5_000,
|
|
242
243
|
});
|
|
243
|
-
const
|
|
244
|
+
const initialIdleWait = state.waitForEvent({
|
|
244
245
|
match: (event) => {
|
|
245
246
|
return event.type === 'session.idle' && event.properties.sessionID === sessionID;
|
|
246
247
|
},
|
|
@@ -250,7 +251,18 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
250
251
|
path: { id: sessionID },
|
|
251
252
|
});
|
|
252
253
|
await abortedAssistantWait;
|
|
253
|
-
await
|
|
254
|
+
await initialIdleWait;
|
|
255
|
+
// OpenCode can emit `session.idle` before the aborted assistant update,
|
|
256
|
+
// then emit another idle after the cancelled run finishes cleanup. Replaying
|
|
257
|
+
// on the first idle can enqueue the replay behind the still-settling run.
|
|
258
|
+
// Some paths only emit the first idle, so wait briefly for a post-abort
|
|
259
|
+
// idle and then continue rather than dropping the user's interrupt.
|
|
260
|
+
await state.waitForEvent({
|
|
261
|
+
match: (event) => {
|
|
262
|
+
return event.type === 'session.idle' && event.properties.sessionID === sessionID;
|
|
263
|
+
},
|
|
264
|
+
timeoutMs: POST_ABORT_IDLE_GRACE_MS,
|
|
265
|
+
});
|
|
254
266
|
const currentPending = state.getPending(messageID);
|
|
255
267
|
if (!currentPending || currentPending.started) {
|
|
256
268
|
state.clearPending(messageID);
|
|
@@ -230,6 +230,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
230
230
|
parentID: REAL_RATE_LIMIT_CASE.previousMessageID,
|
|
231
231
|
}),
|
|
232
232
|
});
|
|
233
|
+
await delay({ ms: 1 });
|
|
234
|
+
await eventHook({
|
|
235
|
+
event: createSessionIdleEvent({ sessionID: REAL_RATE_LIMIT_CASE.sessionID }),
|
|
236
|
+
});
|
|
233
237
|
await delay({ ms: 20 });
|
|
234
238
|
expect(abortCalls).toEqual([{ path: { id: REAL_RATE_LIMIT_CASE.sessionID } }]);
|
|
235
239
|
expect(promptAsyncCalls).toEqual([
|
|
@@ -326,7 +330,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
326
330
|
await chatHook({ sessionID, messageID: userMsgID }, createChatOutput({ sessionID, messageID: userMsgID }));
|
|
327
331
|
// 3. Timeout fires (20ms), plugin runs handleUnsentTimeout
|
|
328
332
|
await delay({ ms: 30 });
|
|
329
|
-
// 4. Simulate abort completing
|
|
333
|
+
// 4. Simulate abort completing. OpenCode can emit an idle event before the
|
|
334
|
+
// aborted assistant update, then emit another idle after cleanup settles.
|
|
335
|
+
// Replaying before that post-abort idle can leave the replayed message
|
|
336
|
+
// queued behind the cancelled run.
|
|
330
337
|
await eventHook({ event: createSessionErrorEvent({ sessionID }) });
|
|
331
338
|
await eventHook({ event: createSessionIdleEvent({ sessionID }) });
|
|
332
339
|
await eventHook({
|
|
@@ -339,6 +346,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
339
346
|
await delay({ ms: 20 });
|
|
340
347
|
// 5. Verify plugin aborted the session
|
|
341
348
|
expect(abortCalls).toEqual([{ path: { id: sessionID } }]);
|
|
349
|
+
expect(promptAsyncCalls).toEqual([]);
|
|
350
|
+
await delay({ ms: 1 });
|
|
351
|
+
await eventHook({ event: createSessionIdleEvent({ sessionID }) });
|
|
352
|
+
await delay({ ms: 20 });
|
|
342
353
|
// 6. Recovery should replay the queued message itself, not an empty
|
|
343
354
|
// resume prompt. This preserves the original messageID + parts after
|
|
344
355
|
// session.abort() clears OpenCode's internal prompt queue.
|
|
@@ -383,8 +394,8 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
383
394
|
messageID: REAL_SLEEP_INTERRUPT_CASE.interruptingMessageID,
|
|
384
395
|
}));
|
|
385
396
|
await delay({ ms: 30 });
|
|
386
|
-
await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
|
|
387
397
|
await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.abortErrorEvent });
|
|
398
|
+
await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
|
|
388
399
|
await eventHook({
|
|
389
400
|
event: createAssistantAbortedEvent({
|
|
390
401
|
sessionID: REAL_SLEEP_INTERRUPT_CASE.sessionID,
|
|
@@ -394,6 +405,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
394
405
|
});
|
|
395
406
|
await delay({ ms: 20 });
|
|
396
407
|
expect(abortCalls).toEqual([{ path: { id: REAL_SLEEP_INTERRUPT_CASE.sessionID } }]);
|
|
408
|
+
expect(promptAsyncCalls).toEqual([]);
|
|
409
|
+
await delay({ ms: 1 });
|
|
410
|
+
await eventHook({ event: REAL_SLEEP_INTERRUPT_CASE.idleEvent });
|
|
411
|
+
await delay({ ms: 20 });
|
|
397
412
|
expect(promptAsyncCalls).toEqual([
|
|
398
413
|
{
|
|
399
414
|
path: { id: REAL_SLEEP_INTERRUPT_CASE.sessionID },
|
|
@@ -440,8 +455,8 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
440
455
|
});
|
|
441
456
|
await delay({ ms: 10 });
|
|
442
457
|
expect(abortCalls).toEqual([{ path: { id: sessionID } }]);
|
|
443
|
-
await eventHook({ event: createSessionIdleEvent({ sessionID }) });
|
|
444
458
|
await eventHook({ event: createSessionErrorEvent({ sessionID }) });
|
|
459
|
+
await eventHook({ event: createSessionIdleEvent({ sessionID }) });
|
|
445
460
|
await eventHook({
|
|
446
461
|
event: createAssistantAbortedEvent({
|
|
447
462
|
sessionID,
|
|
@@ -450,6 +465,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
450
465
|
}),
|
|
451
466
|
});
|
|
452
467
|
await delay({ ms: 20 });
|
|
468
|
+
expect(promptAsyncCalls).toEqual([]);
|
|
469
|
+
await delay({ ms: 1 });
|
|
470
|
+
await eventHook({ event: createSessionIdleEvent({ sessionID }) });
|
|
471
|
+
await delay({ ms: 20 });
|
|
453
472
|
expect(promptAsyncCalls).toEqual([
|
|
454
473
|
{
|
|
455
474
|
path: { id: sessionID },
|
|
@@ -257,7 +257,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
257
257
|
**Deterministic Provider** / **deterministic-v3**
|
|
258
258
|
\`deterministic-provider/deterministic-v3\`
|
|
259
259
|
_Restarting current request with new model..._
|
|
260
|
-
_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_
|
|
260
|
+
_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_
|
|
261
261
|
--- from: user (queue-model-switch-tester)
|
|
262
262
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
263
263
|
--- from: assistant (TestBot)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// Agent preference resolution utility.
|
|
2
2
|
// Validates agent preferences against the OpenCode API.
|
|
3
|
+
// When a requested agent is not found, we fall back to the default agent
|
|
4
|
+
// instead of throwing. This handles stale agent preferences from CLI send
|
|
5
|
+
// commands or database references to agents that were removed from config.
|
|
3
6
|
import * as errore from 'errore';
|
|
4
7
|
import { getSessionAgent, getSessionModel, getChannelAgent, } from '../database.js';
|
|
8
|
+
import { createLogger } from '../logger.js';
|
|
5
9
|
import {} from '../opencode.js';
|
|
6
10
|
import {} from '../system-message.js';
|
|
11
|
+
const agentLogger = createLogger('agent');
|
|
7
12
|
export async function resolveValidatedAgentPreference({ agent, sessionId, channelId, getClient, directory, }) {
|
|
8
13
|
const agentPreference = await (async () => {
|
|
9
14
|
if (agent) {
|
|
@@ -55,13 +60,8 @@ export async function resolveValidatedAgentPreference({ agent, sessionId, channe
|
|
|
55
60
|
if (hasAgent) {
|
|
56
61
|
return { agentPreference, agents };
|
|
57
62
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
.slice(0, 20);
|
|
63
|
-
const availableAgentsMessage = availableAgentNames.length > 0
|
|
64
|
-
? `Available agents: ${availableAgentNames.join(', ')}`
|
|
65
|
-
: 'No agents are available in this project.';
|
|
66
|
-
throw new Error(`Agent "${agentPreference}" not found. ${availableAgentsMessage} Use /agent to choose a valid one.`);
|
|
63
|
+
// Fall back to default agent instead of erroring. This handles stale
|
|
64
|
+
// preferences from CLI send commands or removed agents in config.
|
|
65
|
+
agentLogger.warn(`Agent "${agentPreference}" not found, falling back to default agent`);
|
|
66
|
+
return { agentPreference: undefined, agents };
|
|
67
67
|
}
|
|
@@ -131,6 +131,35 @@ export function removeQueueItemAtPosition(threadId, position) {
|
|
|
131
131
|
});
|
|
132
132
|
return removedItem;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Find a queued item by its Discord source message ID and apply an updater.
|
|
136
|
+
* If the updater returns null, the item is removed from the queue.
|
|
137
|
+
* Returns the original (pre-update) item, or undefined if not found.
|
|
138
|
+
*/
|
|
139
|
+
export function updateQueueItemBySourceMessageId(threadId, sourceMessageId, updater) {
|
|
140
|
+
let originalItem;
|
|
141
|
+
store.setState((s) => {
|
|
142
|
+
const t = s.threads.get(threadId);
|
|
143
|
+
if (!t)
|
|
144
|
+
return s;
|
|
145
|
+
const index = t.queueItems.findIndex((item) => {
|
|
146
|
+
return item.sourceMessageId === sourceMessageId;
|
|
147
|
+
});
|
|
148
|
+
if (index === -1)
|
|
149
|
+
return s;
|
|
150
|
+
originalItem = t.queueItems[index];
|
|
151
|
+
const updated = updater(originalItem);
|
|
152
|
+
const newThreads = new Map(s.threads);
|
|
153
|
+
newThreads.set(threadId, {
|
|
154
|
+
...t,
|
|
155
|
+
queueItems: updated === null
|
|
156
|
+
? t.queueItems.filter((_, i) => i !== index)
|
|
157
|
+
: t.queueItems.map((item, i) => (i === index ? updated : item)),
|
|
158
|
+
});
|
|
159
|
+
return { threads: newThreads };
|
|
160
|
+
});
|
|
161
|
+
return originalItem;
|
|
162
|
+
}
|
|
134
163
|
// ── Queries ──────────────────────────────────────────────────────
|
|
135
164
|
export function getThreadState(threadId) {
|
|
136
165
|
return store.getState().threads.get(threadId);
|