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.
Files changed (47) hide show
  1. package/dist/cli.js +18 -0
  2. package/dist/commands/action-buttons.js +2 -0
  3. package/dist/commands/ask-question.js +2 -0
  4. package/dist/commands/file-upload.js +5 -1
  5. package/dist/commands/model-variant.js +21 -16
  6. package/dist/commands/model.js +41 -13
  7. package/dist/commands/permissions.js +13 -3
  8. package/dist/config.js +8 -0
  9. package/dist/discord-bot.js +25 -3
  10. package/dist/opencode-interrupt-plugin.js +12 -6
  11. package/dist/opencode.js +22 -0
  12. package/dist/queue-advanced-action-buttons.e2e.test.js +1 -0
  13. package/dist/queue-question-select-drain.e2e.test.js +2 -0
  14. package/dist/session-handler/global-event-listener.js +179 -0
  15. package/dist/session-handler/thread-runtime-state.js +0 -1
  16. package/dist/session-handler/thread-session-runtime.js +25 -163
  17. package/dist/store.js +1 -0
  18. package/dist/system-message.js +4 -4
  19. package/dist/system-message.test.js +5 -3
  20. package/dist/thread-message-queue.e2e.test.js +4 -2
  21. package/dist/voice-message.e2e.test.js +1 -1
  22. package/package.json +3 -3
  23. package/skills/holocron/SKILL.md +20 -4
  24. package/skills/new-skill/SKILL.md +7 -7
  25. package/skills/strada/SKILL.md +236 -0
  26. package/skills/termcast/SKILL.md +2 -0
  27. package/src/cli.ts +24 -0
  28. package/src/commands/action-buttons.ts +6 -0
  29. package/src/commands/ask-question.ts +6 -0
  30. package/src/commands/file-upload.ts +9 -1
  31. package/src/commands/model-variant.ts +21 -16
  32. package/src/commands/model.ts +52 -14
  33. package/src/commands/permissions.ts +14 -3
  34. package/src/config.ts +9 -0
  35. package/src/discord-bot.ts +36 -5
  36. package/src/opencode-interrupt-plugin.ts +12 -6
  37. package/src/opencode.ts +24 -0
  38. package/src/queue-advanced-action-buttons.e2e.test.ts +1 -0
  39. package/src/queue-question-select-drain.e2e.test.ts +2 -0
  40. package/src/session-handler/global-event-listener.ts +224 -0
  41. package/src/session-handler/thread-runtime-state.ts +0 -8
  42. package/src/session-handler/thread-session-runtime.ts +29 -202
  43. package/src/store.ts +10 -0
  44. package/src/system-message.test.ts +5 -3
  45. package/src/system-message.ts +8 -4
  46. package/src/thread-message-queue.e2e.test.ts +4 -2
  47. 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, setSessionModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
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 setSessionModel({
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
  });
@@ -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 setSessionModel({ sessionId: context.sessionId, modelId, variant });
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
- const PERMISSION_CONTEXT_TTL_MS = 10 * 60 * 1000;
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: '_Permission expired after 10 minutes and was rejected._',
124
+ status: `_Permission expired after ${minutes} minute${minutes !== 1 ? 's' : ''} and was rejected._`,
115
125
  });
116
126
  }
117
- }, PERMISSION_CONTEXT_TTL_MS).unref();
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.
@@ -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
- discordLogger.log(`[MESSAGE_EDIT] ${result.removed ? 'Removed' : 'Updated'} queued message ${message.id} in thread ${channel.id}`);
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
- // When an assistant message starts processing a queued user message
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
- if (!event.properties.info.error) {
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