kimaki 0.13.1 → 0.14.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/cli.js +18 -0
- package/dist/commands/action-buttons.js +2 -0
- package/dist/commands/ask-question.js +2 -0
- package/dist/commands/file-upload.js +5 -1
- package/dist/commands/model-variant.js +21 -16
- package/dist/commands/model.js +41 -13
- package/dist/commands/permissions.js +13 -3
- package/dist/config.js +8 -0
- package/dist/discord-bot.js +25 -3
- package/dist/opencode-interrupt-plugin.js +12 -6
- package/dist/opencode.js +22 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +1 -0
- package/dist/queue-question-select-drain.e2e.test.js +2 -0
- package/dist/session-handler/global-event-listener.js +179 -0
- package/dist/session-handler/thread-runtime-state.js +0 -1
- package/dist/session-handler/thread-session-runtime.js +25 -163
- package/dist/store.js +1 -0
- package/dist/system-message.js +4 -4
- package/dist/system-message.test.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +4 -2
- package/dist/voice-message.e2e.test.js +1 -1
- package/package.json +3 -3
- package/skills/holocron/SKILL.md +20 -4
- package/skills/new-skill/SKILL.md +7 -7
- package/skills/strada/SKILL.md +236 -0
- package/skills/termcast/SKILL.md +2 -0
- package/src/cli.ts +24 -0
- package/src/commands/action-buttons.ts +6 -0
- package/src/commands/ask-question.ts +6 -0
- package/src/commands/file-upload.ts +9 -1
- package/src/commands/model-variant.ts +21 -16
- package/src/commands/model.ts +52 -14
- package/src/commands/permissions.ts +14 -3
- package/src/config.ts +9 -0
- package/src/discord-bot.ts +36 -5
- package/src/opencode-interrupt-plugin.ts +12 -6
- package/src/opencode.ts +24 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +1 -0
- package/src/queue-question-select-drain.e2e.test.ts +2 -0
- package/src/session-handler/global-event-listener.ts +224 -0
- package/src/session-handler/thread-runtime-state.ts +0 -8
- package/src/session-handler/thread-session-runtime.ts +29 -202
- package/src/store.ts +10 -0
- package/src/system-message.test.ts +5 -3
- package/src/system-message.ts +8 -4
- package/src/thread-message-queue.e2e.test.ts +4 -2
- package/src/voice-message.e2e.test.ts +1 -1
package/dist/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ cli
|
|
|
40
40
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
41
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
42
42
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
43
|
+
.option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
|
|
43
44
|
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
44
45
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
45
46
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
@@ -134,6 +135,19 @@ cli
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
}
|
|
138
|
+
// --permission-timeout-minutes validation
|
|
139
|
+
// Node setTimeout max is 2_147_483_647ms; larger values fire immediately.
|
|
140
|
+
const MAX_TIMEOUT_MINUTES = Math.floor(2_147_483_647 / 60_000);
|
|
141
|
+
const permissionTimeoutMs = (() => {
|
|
142
|
+
if (!options.permissionTimeoutMinutes)
|
|
143
|
+
return undefined;
|
|
144
|
+
const parsed = Number(options.permissionTimeoutMinutes);
|
|
145
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TIMEOUT_MINUTES) {
|
|
146
|
+
cliLogger.error(`Invalid permission timeout: ${options.permissionTimeoutMinutes}. Must be a positive whole number of minutes (max ${MAX_TIMEOUT_MINUTES}).`);
|
|
147
|
+
process.exit(EXIT_NO_RESTART);
|
|
148
|
+
}
|
|
149
|
+
return parsed * 60_000;
|
|
150
|
+
})();
|
|
137
151
|
store.setState({
|
|
138
152
|
...(defaultVerbosity && {
|
|
139
153
|
defaultVerbosity,
|
|
@@ -141,6 +155,7 @@ cli
|
|
|
141
155
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
142
156
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
143
157
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
158
|
+
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
144
159
|
...(options.disableSync && { syncEnabled: false }),
|
|
145
160
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
146
161
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
@@ -155,6 +170,9 @@ cli
|
|
|
155
170
|
if (options.allowAllUsers) {
|
|
156
171
|
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
157
172
|
}
|
|
173
|
+
if (permissionTimeoutMs !== undefined) {
|
|
174
|
+
cliLogger.log(`Permission timeout set to ${options.permissionTimeoutMinutes} minutes`);
|
|
175
|
+
}
|
|
158
176
|
if (options.verbosity) {
|
|
159
177
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
160
178
|
}
|
|
@@ -212,7 +212,9 @@ export async function handleActionButton(interaction) {
|
|
|
212
212
|
content: `**Action Required**\n_Selected: ${button.label}_`,
|
|
213
213
|
components: [],
|
|
214
214
|
});
|
|
215
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
215
216
|
const prompt = `User clicked: ${button.label}`;
|
|
217
|
+
await sendThreadMessage(thread, `» **${username}:** ${button.label}`);
|
|
216
218
|
try {
|
|
217
219
|
await sendClickedActionToModel({
|
|
218
220
|
interaction,
|
|
@@ -183,6 +183,8 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
183
183
|
content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
|
|
184
184
|
components: [], // Remove the dropdown
|
|
185
185
|
});
|
|
186
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
187
|
+
await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
|
|
186
188
|
// Check if all questions are answered
|
|
187
189
|
if (context.answeredCount >= context.totalQuestions) {
|
|
188
190
|
// All questions answered - send result back to session
|
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
13
|
import { notifyError } from '../sentry.js';
|
|
14
|
-
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
14
|
+
import { NOTIFY_MESSAGE_FLAGS, sendThreadMessage } from '../discord-utils.js';
|
|
15
15
|
const logger = createLogger(LogPrefix.FILE_UPLOAD);
|
|
16
16
|
// 5 minute TTL for pending contexts - if user doesn't click within this time,
|
|
17
17
|
// clean up the context and resolve with empty array to unblock the plugin tool
|
|
@@ -219,6 +219,10 @@ export async function handleFileUploadModalSubmit(interaction) {
|
|
|
219
219
|
return `Upload failed: ${errors.join('; ')}`;
|
|
220
220
|
})();
|
|
221
221
|
await interaction.editReply({ content: summary });
|
|
222
|
+
if (downloadedPaths.length > 0) {
|
|
223
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
224
|
+
await sendThreadMessage(context.thread, `» **${username}:** Uploaded ${fileNames.join(', ')}`);
|
|
225
|
+
}
|
|
222
226
|
resolveContext(context, downloadedPaths);
|
|
223
227
|
logger.log(`File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`);
|
|
224
228
|
}
|
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
8
8
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import { setChannelModel,
|
|
10
|
+
import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
11
11
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
12
12
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
13
|
-
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from './model.js';
|
|
14
|
-
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
|
+
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, applyToCurrentSession, } from './model.js';
|
|
15
14
|
import { getThinkingValuesForModel } from '../thinking-utils.js';
|
|
16
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
17
16
|
const logger = createLogger(LogPrefix.MODEL);
|
|
@@ -303,22 +302,14 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
303
302
|
});
|
|
304
303
|
return;
|
|
305
304
|
}
|
|
306
|
-
await
|
|
305
|
+
const retried = await applyToCurrentSession({
|
|
307
306
|
sessionId: context.sessionId,
|
|
307
|
+
thread: context.thread,
|
|
308
308
|
modelId,
|
|
309
309
|
variant,
|
|
310
310
|
});
|
|
311
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
311
312
|
logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
|
|
312
|
-
let retried = false;
|
|
313
|
-
if (context.thread) {
|
|
314
|
-
const runtime = getRuntime(context.thread.id);
|
|
315
|
-
if (runtime) {
|
|
316
|
-
retried = await runtime.retryLastUserPrompt();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const retryNote = retried
|
|
320
|
-
? '\n_Restarting current request with new variant..._'
|
|
321
|
-
: '';
|
|
322
313
|
await interaction.editReply({
|
|
323
314
|
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
|
|
324
315
|
flags: MessageFlags.SuppressEmbeds,
|
|
@@ -332,9 +323,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
332
323
|
modelId,
|
|
333
324
|
variant,
|
|
334
325
|
});
|
|
326
|
+
const retried = await applyToCurrentSession({
|
|
327
|
+
sessionId: context.sessionId,
|
|
328
|
+
thread: context.thread,
|
|
329
|
+
modelId,
|
|
330
|
+
variant,
|
|
331
|
+
});
|
|
332
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
335
333
|
logger.log(`Set global variant ${variant ?? 'none'} for app ${context.appId} and channel ${context.channelId} (model ${modelId})`);
|
|
336
334
|
await interaction.editReply({
|
|
337
|
-
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${agentTip}`,
|
|
335
|
+
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${retryNote}${agentTip}`,
|
|
338
336
|
flags: MessageFlags.SuppressEmbeds,
|
|
339
337
|
components: [],
|
|
340
338
|
});
|
|
@@ -346,9 +344,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
346
344
|
modelId,
|
|
347
345
|
variant,
|
|
348
346
|
});
|
|
347
|
+
const retried = await applyToCurrentSession({
|
|
348
|
+
sessionId: context.sessionId,
|
|
349
|
+
thread: context.thread,
|
|
350
|
+
modelId,
|
|
351
|
+
variant,
|
|
352
|
+
});
|
|
353
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
349
354
|
logger.log(`Set channel variant ${variant ?? 'none'} for channel ${context.channelId} (model ${modelId})`);
|
|
350
355
|
await interaction.editReply({
|
|
351
|
-
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${agentTip}`,
|
|
356
|
+
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${retryNote}${agentTip}`,
|
|
352
357
|
flags: MessageFlags.SuppressEmbeds,
|
|
353
358
|
components: [],
|
|
354
359
|
});
|
package/dist/commands/model.js
CHANGED
|
@@ -693,6 +693,24 @@ async function showScopeMenu({ interaction, contextHash, context, }) {
|
|
|
693
693
|
components: [actionRow],
|
|
694
694
|
});
|
|
695
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* If a session is active, also update its model preference and retry.
|
|
698
|
+
* Returns true if the current request was restarted.
|
|
699
|
+
*/
|
|
700
|
+
export async function applyToCurrentSession({ sessionId, thread, modelId, variant, }) {
|
|
701
|
+
if (!sessionId) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
await setSessionModel({ sessionId, modelId, variant });
|
|
705
|
+
if (!thread) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
const runtime = getRuntime(thread.id);
|
|
709
|
+
if (!runtime) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
return runtime.retryLastUserPrompt();
|
|
713
|
+
}
|
|
696
714
|
/**
|
|
697
715
|
* Handle the scope select menu interaction.
|
|
698
716
|
* Applies the model to either the channel or globally.
|
|
@@ -739,18 +757,14 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
739
757
|
});
|
|
740
758
|
return;
|
|
741
759
|
}
|
|
742
|
-
await
|
|
760
|
+
const retried = await applyToCurrentSession({
|
|
761
|
+
sessionId: context.sessionId,
|
|
762
|
+
thread: context.thread,
|
|
763
|
+
modelId,
|
|
764
|
+
variant,
|
|
765
|
+
});
|
|
766
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
743
767
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
|
|
744
|
-
let retried = false;
|
|
745
|
-
if (context.thread) {
|
|
746
|
-
const runtime = getRuntime(context.thread.id);
|
|
747
|
-
if (runtime) {
|
|
748
|
-
retried = await runtime.retryLastUserPrompt();
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const retryNote = retried
|
|
752
|
-
? '\n_Restarting current request with new model..._'
|
|
753
|
-
: '';
|
|
754
768
|
await interaction.editReply({
|
|
755
769
|
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
|
|
756
770
|
flags: MessageFlags.SuppressEmbeds,
|
|
@@ -768,9 +782,16 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
768
782
|
}
|
|
769
783
|
await setGlobalModel({ appId: context.appId, modelId, variant });
|
|
770
784
|
await setChannelModel({ channelId: context.channelId, modelId, variant });
|
|
785
|
+
const retried = await applyToCurrentSession({
|
|
786
|
+
sessionId: context.sessionId,
|
|
787
|
+
thread: context.thread,
|
|
788
|
+
modelId,
|
|
789
|
+
variant,
|
|
790
|
+
});
|
|
791
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
771
792
|
modelLogger.log(`Set global model ${modelId}${variantSuffix} for app ${context.appId} and channel ${context.channelId}`);
|
|
772
793
|
await interaction.editReply({
|
|
773
|
-
content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${agentTip}`,
|
|
794
|
+
content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${retryNote}${agentTip}`,
|
|
774
795
|
flags: MessageFlags.SuppressEmbeds,
|
|
775
796
|
components: [],
|
|
776
797
|
});
|
|
@@ -778,9 +799,16 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
778
799
|
else {
|
|
779
800
|
// channel scope
|
|
780
801
|
await setChannelModel({ channelId: context.channelId, modelId, variant });
|
|
802
|
+
const retried = await applyToCurrentSession({
|
|
803
|
+
sessionId: context.sessionId,
|
|
804
|
+
thread: context.thread,
|
|
805
|
+
modelId,
|
|
806
|
+
variant,
|
|
807
|
+
});
|
|
808
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
781
809
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for channel ${context.channelId}`);
|
|
782
810
|
await interaction.editReply({
|
|
783
|
-
content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${agentTip}`,
|
|
811
|
+
content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${retryNote}${agentTip}`,
|
|
784
812
|
flags: MessageFlags.SuppressEmbeds,
|
|
785
813
|
components: [],
|
|
786
814
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags, } from 'discord.js';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { getOpencodeClient } from '../opencode.js';
|
|
7
|
+
import { getPermissionTimeoutMs } from '../config.js';
|
|
7
8
|
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
8
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
10
|
const logger = createLogger(LogPrefix.PERMISSIONS);
|
|
@@ -59,7 +60,7 @@ export function compactPermissionPatterns(patterns) {
|
|
|
59
60
|
}
|
|
60
61
|
// Store pending permission contexts by hash.
|
|
61
62
|
// TTL prevents unbounded growth if user never clicks a permission button.
|
|
62
|
-
|
|
63
|
+
// Configurable via --permission-timeout-minutes CLI flag (default: 10 minutes).
|
|
63
64
|
export const pendingPermissionContexts = new Map();
|
|
64
65
|
// Atomic take: removes context from Map and returns it. Only the first caller
|
|
65
66
|
// (TTL expiry or button click) wins, preventing duplicate permission replies.
|
|
@@ -90,6 +91,9 @@ export async function showPermissionButtons({ thread, permission, directory, per
|
|
|
90
91
|
// Auto-reject on TTL expiry so the OpenCode session doesn't hang forever
|
|
91
92
|
// waiting for a permission reply that will never come. Uses atomic take
|
|
92
93
|
// so only one of TTL-expiry or button-click can win.
|
|
94
|
+
// With continue_loop_on_deny enabled in opencode config, the model sees
|
|
95
|
+
// this as a tool error and continues (tries alternatives or explains).
|
|
96
|
+
const ttlMs = getPermissionTimeoutMs();
|
|
93
97
|
setTimeout(async () => {
|
|
94
98
|
const ctx = takePendingPermissionContext(contextHash);
|
|
95
99
|
if (!ctx) {
|
|
@@ -100,21 +104,27 @@ export async function showPermissionButtons({ thread, permission, directory, per
|
|
|
100
104
|
const requestIds = ctx.requestIds.length > 0
|
|
101
105
|
? ctx.requestIds
|
|
102
106
|
: [ctx.permission.id];
|
|
107
|
+
const userId = ctx.thread.ownerId;
|
|
108
|
+
const timeoutFeedback = `Permission timed out — the user did not respond. They are probably away and not watching the session. ` +
|
|
109
|
+
`If this tool call is necessary for the core goal of this session, stop and mention the user with <@${userId}> asking them to grant permission. ` +
|
|
110
|
+
`If not, continue normally — work around it, skip the tool, or use an alternative approach.`;
|
|
103
111
|
await Promise.all(requestIds.map((requestId) => {
|
|
104
112
|
return client.permission.reply({
|
|
105
113
|
requestID: requestId,
|
|
106
114
|
directory: ctx.permissionDirectory,
|
|
107
115
|
reply: 'reject',
|
|
116
|
+
message: timeoutFeedback,
|
|
108
117
|
});
|
|
109
118
|
})).catch((error) => {
|
|
110
119
|
logger.error('Failed to auto-reject expired permission:', error);
|
|
111
120
|
});
|
|
121
|
+
const minutes = Math.round(ttlMs / 60_000);
|
|
112
122
|
updatePermissionMessage({
|
|
113
123
|
context: ctx,
|
|
114
|
-
status:
|
|
124
|
+
status: `_Permission expired after ${minutes} minute${minutes !== 1 ? 's' : ''} and was rejected._`,
|
|
115
125
|
});
|
|
116
126
|
}
|
|
117
|
-
},
|
|
127
|
+
}, ttlMs).unref();
|
|
118
128
|
const patternStr = compactPermissionPatterns(permission.patterns).join(', ');
|
|
119
129
|
// Build 3 buttons for permission actions
|
|
120
130
|
const acceptButton = new ButtonBuilder()
|
package/dist/config.js
CHANGED
|
@@ -62,6 +62,14 @@ export function setProjectsDir(dir) {
|
|
|
62
62
|
}
|
|
63
63
|
store.setState({ projectsDir: resolvedDir });
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Get the permission button timeout in milliseconds.
|
|
67
|
+
* How long permission buttons remain active before auto-rejecting.
|
|
68
|
+
* Defaults to 10 minutes (600000ms).
|
|
69
|
+
*/
|
|
70
|
+
export function getPermissionTimeoutMs() {
|
|
71
|
+
return store.getState().permissionTimeoutMs;
|
|
72
|
+
}
|
|
65
73
|
const DEFAULT_LOCK_PORT = 29988;
|
|
66
74
|
/**
|
|
67
75
|
* Derive a lock port from the data directory path.
|
package/dist/discord-bot.js
CHANGED
|
@@ -569,7 +569,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
569
569
|
});
|
|
570
570
|
// Notify when a voice message was queued instead of sent immediately
|
|
571
571
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
572
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}`);
|
|
572
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update it in queue`);
|
|
573
573
|
}
|
|
574
574
|
}
|
|
575
575
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -777,8 +777,16 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
777
777
|
// If the edit removed the queue suffix, remove the item from the queue.
|
|
778
778
|
// If the suffix is still present, update the prompt.
|
|
779
779
|
const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
|
|
780
|
-
if (result.found) {
|
|
781
|
-
|
|
780
|
+
if (result.found && channel.isThread()) {
|
|
781
|
+
const displayName = message.member?.displayName ?? message.author.displayName;
|
|
782
|
+
if (result.removed) {
|
|
783
|
+
discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
784
|
+
await sendThreadMessage(channel, `⬦ **${displayName}** removed message from queue`);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
|
|
788
|
+
await sendThreadMessage(channel, `⬦ **${displayName}** edited queued message`);
|
|
789
|
+
}
|
|
782
790
|
}
|
|
783
791
|
}
|
|
784
792
|
catch (error) {
|
|
@@ -1010,6 +1018,20 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1010
1018
|
startHeapMonitor();
|
|
1011
1019
|
const stopTaskRunner = startTaskRunner({ token });
|
|
1012
1020
|
const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
|
|
1021
|
+
// Prevent discord.js from permanently killing the REST token on 401.
|
|
1022
|
+
// @discordjs/rest calls setToken(null) whenever it receives a 401 response.
|
|
1023
|
+
// The gateway proxy now returns 503 for stale-DB rejections (not 401), but
|
|
1024
|
+
// this guard stays as defense-in-depth for any other transient 401 source.
|
|
1025
|
+
// Allows null through when Client.destroy() is running (it sets client.token
|
|
1026
|
+
// = null before calling rest.setToken(null)).
|
|
1027
|
+
const originalSetToken = discordClient.rest.setToken.bind(discordClient.rest);
|
|
1028
|
+
discordClient.rest.setToken = (newToken) => {
|
|
1029
|
+
if (!newToken && discordClient.token !== null) {
|
|
1030
|
+
discordLogger.warn('[REST] Blocked token nullification from 401 response');
|
|
1031
|
+
return discordClient.rest;
|
|
1032
|
+
}
|
|
1033
|
+
return originalSetToken(newToken);
|
|
1034
|
+
};
|
|
1013
1035
|
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
1014
1036
|
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
1015
1037
|
if (global.shuttingDown) {
|
|
@@ -190,19 +190,25 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
190
190
|
}
|
|
191
191
|
return {
|
|
192
192
|
async event({ event }) {
|
|
193
|
-
//
|
|
194
|
-
// (parentID match), cancel its interrupt timer: the message began running
|
|
195
|
-
// normally within the timeout window, so there is nothing to interrupt.
|
|
193
|
+
// Clear timer even for errored assistant messages — the LLM processed it.
|
|
196
194
|
if (event.type === 'message.updated' && event.properties.info.role === 'assistant') {
|
|
197
|
-
|
|
198
|
-
clearPending(event.properties.info.parentID);
|
|
199
|
-
}
|
|
195
|
+
clearPending(event.properties.info.parentID);
|
|
200
196
|
return;
|
|
201
197
|
}
|
|
202
198
|
if (event.type === 'session.deleted') {
|
|
203
199
|
log('debug', 'session deleted, cleaning up', { sessionID: event.properties.info.id });
|
|
204
200
|
cleanupSession(event.properties.info.id);
|
|
205
201
|
}
|
|
202
|
+
// Clear stale timers so they don't abort a later unrelated generation.
|
|
203
|
+
// Skip when an interrupt is in flight — abort sets the session idle
|
|
204
|
+
// synchronously, and cleaning up here would drop the pending replay.
|
|
205
|
+
if (event.type === 'session.idle') {
|
|
206
|
+
const idleSessionID = event.properties.sessionID;
|
|
207
|
+
if (interrupting.has(idleSessionID))
|
|
208
|
+
return;
|
|
209
|
+
log('debug', 'session idle, clearing pending timers', { sessionID: idleSessionID });
|
|
210
|
+
cleanupSession(idleSessionID);
|
|
211
|
+
}
|
|
206
212
|
},
|
|
207
213
|
async 'chat.message'(input, output) {
|
|
208
214
|
const sessionID = input.sessionID;
|
package/dist/opencode.js
CHANGED
|
@@ -22,6 +22,7 @@ import readline from 'node:readline';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
|
|
25
|
+
import { restartGlobalEventListener } from './session-handler/global-event-listener.js';
|
|
25
26
|
import { getDataDir, getLockPort, } from './config.js';
|
|
26
27
|
import { store } from './store.js';
|
|
27
28
|
import { getHranaUrl } from './hrana-server.js';
|
|
@@ -521,6 +522,12 @@ async function startSingleServer({ directory, } = {}) {
|
|
|
521
522
|
},
|
|
522
523
|
},
|
|
523
524
|
},
|
|
525
|
+
// When a permission prompt times out and is auto-rejected, the model sees
|
|
526
|
+
// the rejection as a tool error and continues working (tries alternatives
|
|
527
|
+
// or explains it couldn't proceed) instead of the session going dead.
|
|
528
|
+
experimental: {
|
|
529
|
+
continue_loop_on_deny: true,
|
|
530
|
+
},
|
|
524
531
|
skills: {
|
|
525
532
|
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
526
533
|
},
|
|
@@ -680,10 +687,18 @@ function getOrCreateClient({ baseUrl, directory, }) {
|
|
|
680
687
|
// @ts-ignore
|
|
681
688
|
timeout: false,
|
|
682
689
|
});
|
|
690
|
+
const serverPassword = process.env.OPENCODE_SERVER_PASSWORD;
|
|
691
|
+
const serverUsername = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
|
|
692
|
+
const authHeaders = {};
|
|
693
|
+
if (serverPassword) {
|
|
694
|
+
const encoded = Buffer.from(`${serverUsername}:${serverPassword}`).toString('base64');
|
|
695
|
+
authHeaders['Authorization'] = `Basic ${encoded}`;
|
|
696
|
+
}
|
|
683
697
|
const client = createOpencodeClient({
|
|
684
698
|
baseUrl,
|
|
685
699
|
directory,
|
|
686
700
|
fetch: fetchWithTimeout,
|
|
701
|
+
headers: authHeaders,
|
|
687
702
|
});
|
|
688
703
|
clientCache.set(directory, client);
|
|
689
704
|
return client;
|
|
@@ -920,6 +935,9 @@ export function readInjectionGuardConfig({ sessionId }) {
|
|
|
920
935
|
export function getOpencodeServerPort(_directory) {
|
|
921
936
|
return singleServer?.port ?? null;
|
|
922
937
|
}
|
|
938
|
+
export function getOpencodeServerBaseUrl() {
|
|
939
|
+
return singleServer?.baseUrl ?? null;
|
|
940
|
+
}
|
|
923
941
|
export function getOpencodeClient(directory) {
|
|
924
942
|
if (!singleServer) {
|
|
925
943
|
return null;
|
|
@@ -981,6 +999,10 @@ export async function stopOpencodeServer() {
|
|
|
981
999
|
singleServer = null;
|
|
982
1000
|
clientCache.clear();
|
|
983
1001
|
serverRetryCount = 0;
|
|
1002
|
+
// Don't dispose the global listener here — it will reconnect when
|
|
1003
|
+
// the server restarts. Only abort the current SSE connection so it
|
|
1004
|
+
// doesn't hang on a dead server.
|
|
1005
|
+
restartGlobalEventListener();
|
|
984
1006
|
await new Promise((resolve) => {
|
|
985
1007
|
setTimeout(resolve, 1000);
|
|
986
1008
|
});
|
|
@@ -128,6 +128,7 @@ describe('queue advanced: action buttons', () => {
|
|
|
128
128
|
**Action Required**
|
|
129
129
|
_Selected: Continue action-buttons flow_
|
|
130
130
|
[user clicks button]
|
|
131
|
+
» **queue-action-tester:** Continue action-buttons flow
|
|
131
132
|
⬥ action-buttons-click-continued
|
|
132
133
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
133
134
|
`);
|
|
@@ -129,6 +129,7 @@ describe('queue drain after question select answer', () => {
|
|
|
129
129
|
» **question-select-tester:** Reply with exactly: post-question-drain
|
|
130
130
|
Queued message (position 1)
|
|
131
131
|
[user selects dropdown: 0]
|
|
132
|
+
» **question-select-tester:** Alpha
|
|
132
133
|
⬥ ok
|
|
133
134
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
134
135
|
`);
|
|
@@ -244,6 +245,7 @@ describe('queue drain after question select answer', () => {
|
|
|
244
245
|
[user interaction]
|
|
245
246
|
Queued message (position 1)
|
|
246
247
|
[user selects dropdown: 0]
|
|
248
|
+
» **question-select-tester:** Alpha
|
|
247
249
|
⬥ slow-response-started
|
|
248
250
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
249
251
|
» **question-select-tester:** Reply with exactly: post-question-second
|