@otto-assistant/otto 0.7.15 → 0.7.17

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.
@@ -429,6 +429,40 @@ describe('/model', () => {
429
429
  Select a provider:"
430
430
  `);
431
431
  });
432
+ test('shows provider list error details', async () => {
433
+ const textChannel = { id: 'channel-1', type: ChannelType.GuildText };
434
+ const { command } = createCommand({ channel: textChannel });
435
+ mockGetOttoMetadata.mockResolvedValue({ projectDirectory: '/repo' });
436
+ mockInitializeOpencodeForDirectory.mockResolvedValue(() => {
437
+ return {
438
+ provider: {
439
+ list: vi.fn(async () => {
440
+ return {
441
+ error: {
442
+ name: 'UnknownError',
443
+ data: {
444
+ message: 'Cursor token refresh failed: {"code":"internal","message":"Error"}',
445
+ },
446
+ },
447
+ };
448
+ }),
449
+ },
450
+ };
451
+ });
452
+ await handleModelCommand({
453
+ interaction: command,
454
+ appId: 'app-1',
455
+ });
456
+ expect(command.editReply.mock.calls).toMatchInlineSnapshot(`
457
+ [
458
+ [
459
+ {
460
+ "content": "Failed to fetch providers: Cursor token refresh failed: {"code":"internal","message":"Error"}",
461
+ },
462
+ ],
463
+ ]
464
+ `);
465
+ });
432
466
  });
433
467
  describe('/agent', () => {
434
468
  test('shows selectable agents and current channel override', async () => {
@@ -13,6 +13,7 @@ import crypto from 'node:crypto';
13
13
  import { initializeOpencodeForDirectory, getOpencodeServerPort, } from '../opencode.js';
14
14
  import { resolveTextChannel, getOttoMetadata } from '../discord-utils.js';
15
15
  import { createLogger, LogPrefix } from '../logger.js';
16
+ import { formatOpenCodeResponseError } from '../errors.js';
16
17
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
17
18
  const loginLogger = createLogger(LogPrefix.LOGIN);
18
19
  // ── Context store ───────────────────────────────────────────────
@@ -141,7 +142,9 @@ export async function handleLoginCommand({ interaction, }) {
141
142
  directory: projectDirectory,
142
143
  });
143
144
  if (!providersResponse.data) {
144
- await interaction.editReply({ content: 'Failed to fetch providers' });
145
+ await interaction.editReply({
146
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
147
+ });
145
148
  return;
146
149
  }
147
150
  const { all: allProviders, connected } = providersResponse.data;
@@ -272,7 +275,10 @@ async function handleProviderStep(interaction, ctx, hash, providerId) {
272
275
  }
273
276
  const providersResponse = await getClient().provider.list({ directory: ctx.dir });
274
277
  if (!providersResponse.data) {
275
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
278
+ await interaction.editReply({
279
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
280
+ components: [],
281
+ });
276
282
  return;
277
283
  }
278
284
  const { all: allProviders, connected } = providersResponse.data;
@@ -5,7 +5,7 @@
5
5
  // Cross-menu state: Discord doesn't expose already-selected values on sibling
6
6
  // select menus in the same message. We track partial selections in the context
7
7
  // Map. Whichever menu fires second sees the first selection stored and applies.
8
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
8
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
9
9
  import crypto from 'node:crypto';
10
10
  import { setChannelModel, setSessionModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
11
11
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -14,6 +14,7 @@ import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from './model.
14
14
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
15
15
  import { getThinkingValuesForModel } from '../thinking-utils.js';
16
16
  import { createLogger, LogPrefix } from '../logger.js';
17
+ import { formatOpenCodeResponseError } from '../errors.js';
17
18
  const logger = createLogger(LogPrefix.MODEL);
18
19
  const pendingVariantContexts = new Map();
19
20
  /** 10 minute TTL for pending contexts to prevent unbounded map growth */
@@ -121,7 +122,9 @@ export async function handleModelVariantCommand({ interaction, appId, }) {
121
122
  return;
122
123
  }
123
124
  if (!providersResponse.data) {
124
- await interaction.editReply({ content: 'Failed to fetch providers' });
125
+ await interaction.editReply({
126
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
127
+ });
125
128
  return;
126
129
  }
127
130
  const { providerID, modelID, model: fullModelId } = currentModelInfo;
@@ -310,21 +313,20 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
310
313
  variant,
311
314
  });
312
315
  logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
313
- let retried = false;
314
- if (context.thread) {
315
- const runtime = getRuntime(context.thread.id);
316
- if (runtime) {
317
- retried = await runtime.retryLastUserPrompt();
318
- }
319
- }
320
- const retryNote = retried
321
- ? '\n_Restarting current request with new variant..._'
322
- : '';
316
+ // Show a button so the user can choose to resend the last message.
317
+ // Do not auto-retry — the user may want to add context in a new message instead.
318
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
319
+ const retryButton = new ButtonBuilder()
320
+ .setCustomId(`variant_retry:${contextHash}`)
321
+ .setLabel('Resend last message')
322
+ .setStyle(ButtonStyle.Secondary);
323
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
323
324
  await interaction.editReply({
324
- content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
325
+ content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
325
326
  flags: MessageFlags.SuppressEmbeds,
326
- components: [],
327
+ components: [actionRow],
327
328
  });
329
+ return;
328
330
  }
329
331
  else if (scope === 'global') {
330
332
  await setGlobalModel({ appId: context.appId, modelId, variant });
@@ -354,7 +356,11 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
354
356
  components: [],
355
357
  });
356
358
  }
357
- pendingVariantContexts.delete(contextHash);
359
+ // Clean up the context from memory, except for session scope where the
360
+ // retry button still needs it. TTL handles cleanup in that case.
361
+ if (scope !== 'session') {
362
+ pendingVariantContexts.delete(contextHash);
363
+ }
358
364
  }
359
365
  catch (error) {
360
366
  logger.error('Error applying variant:', error);
@@ -364,3 +370,41 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
364
370
  });
365
371
  }
366
372
  }
373
+ /**
374
+ * Handle the "Resend last message" button shown after a session-scope variant change.
375
+ * Aborts any active run and resends the last user prompt with the new variant.
376
+ */
377
+ export async function handleVariantRetryButton(interaction) {
378
+ const customId = interaction.customId;
379
+ if (!customId.startsWith('variant_retry:')) {
380
+ return;
381
+ }
382
+ await interaction.deferUpdate();
383
+ const contextHash = customId.replace('variant_retry:', '');
384
+ const context = pendingVariantContexts.get(contextHash);
385
+ // Context may have expired (TTL) — still allow the action via runtime alone.
386
+ const threadId = context?.thread?.id;
387
+ if (!threadId) {
388
+ await interaction.editReply({
389
+ content: 'Could not find the session thread. Please resend your message manually.',
390
+ components: [],
391
+ });
392
+ return;
393
+ }
394
+ const runtime = getRuntime(threadId);
395
+ if (!runtime) {
396
+ await interaction.editReply({
397
+ content: 'No active session found. Please resend your message manually.',
398
+ components: [],
399
+ });
400
+ return;
401
+ }
402
+ const retried = await runtime.retryLastUserPrompt();
403
+ pendingVariantContexts.delete(contextHash);
404
+ await interaction.editReply({
405
+ content: retried
406
+ ? `Restarting with new variant...`
407
+ : `Variant changed. Send a new message to continue.`,
408
+ components: [],
409
+ });
410
+ }
@@ -1,5 +1,5 @@
1
1
  // /model command - Set the preferred model for this channel or session.
2
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
3
3
  import crypto from 'node:crypto';
4
4
  import { setChannelModel, setSessionModel, setSessionAgent, getChannelModel, getSessionModel, getSessionAgent, getChannelAgent, getThreadSession, getGlobalModel, setGlobalModel, getVariantCascade, } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -8,6 +8,7 @@ import { getDefaultModel } from '../session-handler/model-utils.js';
8
8
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { getThinkingValuesForModel } from '../thinking-utils.js';
10
10
  import { createLogger, LogPrefix } from '../logger.js';
11
+ import { formatOpenCodeResponseError } from '../errors.js';
11
12
  import * as errore from 'errore';
12
13
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
13
14
  const modelLogger = createLogger(LogPrefix.MODEL);
@@ -270,7 +271,7 @@ export async function handleModelCommand({ interaction, appId, }) {
270
271
  ]);
271
272
  if (!providersResponse.data) {
272
273
  await interaction.editReply({
273
- content: 'Failed to fetch providers',
274
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
274
275
  });
275
276
  return;
276
277
  }
@@ -393,7 +394,10 @@ export async function handleProviderSelectMenu(interaction) {
393
394
  }
394
395
  const providersResponse = await getClient().provider.list({ directory: context.dir });
395
396
  if (!providersResponse.data) {
396
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
397
+ await interaction.editReply({
398
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
399
+ components: [],
400
+ });
397
401
  return;
398
402
  }
399
403
  const { all: allProviders, connected } = providersResponse.data;
@@ -434,7 +438,7 @@ export async function handleProviderSelectMenu(interaction) {
434
438
  });
435
439
  if (!providersResponse.data) {
436
440
  await interaction.editReply({
437
- content: 'Failed to fetch providers',
441
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
438
442
  components: [],
439
443
  });
440
444
  return;
@@ -737,21 +741,20 @@ export async function handleModelScopeSelectMenu(interaction) {
737
741
  }
738
742
  await setSessionModel({ sessionId: context.sessionId, modelId, variant });
739
743
  modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
740
- let retried = false;
741
- if (context.thread) {
742
- const runtime = getRuntime(context.thread.id);
743
- if (runtime) {
744
- retried = await runtime.retryLastUserPrompt();
745
- }
746
- }
747
- const retryNote = retried
748
- ? '\n_Restarting current request with new model..._'
749
- : '';
744
+ // Show a button so the user can choose to resend the last message.
745
+ // Do not auto-retry — the user may want to add context in a new message instead.
746
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
747
+ const retryButton = new ButtonBuilder()
748
+ .setCustomId(`model_retry:${contextHash}`)
749
+ .setLabel('Resend last message')
750
+ .setStyle(ButtonStyle.Secondary);
751
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
750
752
  await interaction.editReply({
751
- content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
753
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
752
754
  flags: MessageFlags.SuppressEmbeds,
753
- components: [],
755
+ components: [actionRow],
754
756
  });
757
+ return;
755
758
  }
756
759
  else if (selectedScope === 'global') {
757
760
  if (!context.appId) {
@@ -781,8 +784,11 @@ export async function handleModelScopeSelectMenu(interaction) {
781
784
  components: [],
782
785
  });
783
786
  }
784
- // Clean up the context from memory
785
- deleteModelContext(contextHash);
787
+ // Clean up the context from memory, except for session scope where the
788
+ // retry button still needs it. TTL handles cleanup in that case.
789
+ if (selectedScope !== 'session') {
790
+ deleteModelContext(contextHash);
791
+ }
786
792
  }
787
793
  catch (error) {
788
794
  modelLogger.error('Error saving model preference:', error);
@@ -792,3 +798,41 @@ export async function handleModelScopeSelectMenu(interaction) {
792
798
  });
793
799
  }
794
800
  }
801
+ /**
802
+ * Handle the "Resend last message" button shown after a session-scope model change.
803
+ * Aborts any active run and resends the last user prompt with the new model.
804
+ */
805
+ export async function handleModelRetryButton(interaction) {
806
+ const customId = interaction.customId;
807
+ if (!customId.startsWith('model_retry:')) {
808
+ return;
809
+ }
810
+ await interaction.deferUpdate();
811
+ const contextHash = customId.replace('model_retry:', '');
812
+ const context = pendingModelContexts.get(contextHash);
813
+ // Context may have expired (10 min TTL) — still allow the action via runtime alone.
814
+ const threadId = context?.thread?.id;
815
+ if (!threadId) {
816
+ await interaction.editReply({
817
+ content: 'Could not find the session thread. Please resend your message manually.',
818
+ components: [],
819
+ });
820
+ return;
821
+ }
822
+ const runtime = getRuntime(threadId);
823
+ if (!runtime) {
824
+ await interaction.editReply({
825
+ content: 'No active session found. Please resend your message manually.',
826
+ components: [],
827
+ });
828
+ return;
829
+ }
830
+ const retried = await runtime.retryLastUserPrompt();
831
+ deleteModelContext(contextHash);
832
+ await interaction.editReply({
833
+ content: retried
834
+ ? `Restarting with new model...`
835
+ : `Model changed. Send a new message to continue.`,
836
+ components: [],
837
+ });
838
+ }
@@ -1,5 +1,5 @@
1
1
  // /unset-model-override command - Remove model overrides and use default instead.
2
- import { ChatInputCommandInteraction, ChannelType, MessageFlags, } from 'discord.js';
2
+ import { ChatInputCommandInteraction, ButtonInteraction, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
3
3
  import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
4
4
  import { getPrisma } from '../db.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -121,19 +121,58 @@ export async function handleUnsetModelCommand({ interaction, appId, }) {
121
121
  ? 'none'
122
122
  : `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`;
123
123
  }
124
- // Check if there's a running request and abort+retry with new model (only for session changes in threads)
125
- let retried = false;
124
+ const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
125
+ // Show a button so the user can choose to resend the last message (session scope only).
126
+ // Do not auto-retry — the user may want to add context in a new message instead.
126
127
  if (isThread && clearedType === 'session' && sessionId) {
127
128
  const runtime = getRuntime(channel.id);
128
129
  if (runtime) {
129
- retried = await runtime.retryLastUserPrompt();
130
+ const retryButton = new ButtonBuilder()
131
+ .setCustomId(`unset_model_retry:${channel.id}`)
132
+ .setLabel('Resend last message')
133
+ .setStyle(ButtonStyle.Secondary);
134
+ const actionRow = new ActionRowBuilder().addComponents(retryButton);
135
+ await interaction.editReply({
136
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
137
+ components: [actionRow],
138
+ });
139
+ return;
130
140
  }
131
141
  }
132
- const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
133
- const retriedText = retried
134
- ? '\n_Restarting current request with new model..._'
135
- : '';
136
142
  await interaction.editReply({
137
- content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}${retriedText}`,
143
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
144
+ });
145
+ }
146
+ /**
147
+ * Handle the "Resend last message" button after clearing a session model override.
148
+ */
149
+ export async function handleUnsetModelRetryButton(interaction) {
150
+ const customId = interaction.customId;
151
+ if (!customId.startsWith('unset_model_retry:')) {
152
+ return;
153
+ }
154
+ await interaction.deferUpdate();
155
+ const threadId = customId.replace('unset_model_retry:', '');
156
+ if (!threadId) {
157
+ await interaction.editReply({
158
+ content: 'Could not find the session. Please resend your message manually.',
159
+ components: [],
160
+ });
161
+ return;
162
+ }
163
+ const runtime = getRuntime(threadId);
164
+ if (!runtime) {
165
+ await interaction.editReply({
166
+ content: 'No active session found. Please resend your message manually.',
167
+ components: [],
168
+ });
169
+ return;
170
+ }
171
+ const retried = await runtime.retryLastUserPrompt();
172
+ await interaction.editReply({
173
+ content: retried
174
+ ? 'Restarting with new model...'
175
+ : 'Model changed. Send a new message to continue.',
176
+ components: [],
138
177
  });
139
178
  }
@@ -43,11 +43,11 @@ import * as errore from "errore";
43
43
  import { createLogger, formatErrorWithStack, LogPrefix } from "./logger.js";
44
44
  import { writeHeapSnapshot, startHeapMonitor } from "./heap-monitor.js";
45
45
  import { startTaskRunner } from "./task-runner.js";
46
+ import { setGlobalDispatcher, Agent } from "undici";
46
47
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
47
48
  // Each session's event.subscribe() holds a connection; without enough connections,
48
49
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
49
- // undici is a transitive dep from discord.js — not listed in our package.json.
50
- // Types are declared in src/undici.d.ts.
50
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
51
51
  const discordLogger = createLogger(LogPrefix.DISCORD);
52
52
  const voiceLogger = createLogger(LogPrefix.VOICE);
53
53
  // Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
package/dist/errors.js CHANGED
@@ -155,3 +155,25 @@ export class GitCommandError extends createTaggedError({
155
155
  message: 'Git command failed: $command',
156
156
  }) {
157
157
  }
158
+ export function formatOpenCodeResponseError(error) {
159
+ if (error && typeof error === 'object') {
160
+ if ('data' in error &&
161
+ error.data &&
162
+ typeof error.data === 'object' &&
163
+ 'message' in error.data) {
164
+ return String(error.data.message);
165
+ }
166
+ if ('errors' in error &&
167
+ Array.isArray(error.errors) &&
168
+ error.errors.length > 0) {
169
+ return JSON.stringify(error.errors);
170
+ }
171
+ if ('message' in error && typeof error.message === 'string') {
172
+ return error.message;
173
+ }
174
+ if ('name' in error && typeof error.name === 'string') {
175
+ return error.name;
176
+ }
177
+ }
178
+ return 'Unknown OpenCode API error';
179
+ }
@@ -21,8 +21,8 @@ import { handleDiffCommand } from './commands/diff.js';
21
21
  import { handleForkCommand, handleForkSelectMenu, } from './commands/fork.js';
22
22
  import { handleForkSubagentCommand, handleForkSubagentSelectMenu, } from './commands/fork-subagent.js';
23
23
  import { handleBtwCommand } from './commands/btw.js';
24
- import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
25
- import { handleUnsetModelCommand } from './commands/unset-model.js';
24
+ import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, handleModelRetryButton, } from './commands/model.js';
25
+ import { handleUnsetModelCommand, handleUnsetModelRetryButton, } from './commands/unset-model.js';
26
26
  import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
27
27
  import { handleTranscriptionApiKeyButton, handleTranscriptionApiKeyCommand, handleTranscriptionApiKeyModalSubmit, } from './commands/gemini-apikey.js';
28
28
  import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand, } from './commands/agent.js';
@@ -44,7 +44,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
44
44
  import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
45
45
  import { handleVscodeCommand } from './commands/vscode.js';
46
46
  import { handleModelVariantSelectMenu } from './commands/model.js';
47
- import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
47
+ import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, handleVariantRetryButton, } from './commands/model-variant.js';
48
48
  import { hasOttoBotPermission } from './discord-utils.js';
49
49
  import { createLogger, LogPrefix } from './logger.js';
50
50
  import { notifyError } from './sentry.js';
@@ -302,6 +302,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
302
302
  await handleHtmlActionButton(interaction);
303
303
  return;
304
304
  }
305
+ if (customId.startsWith('model_retry:')) {
306
+ await handleModelRetryButton(interaction);
307
+ return;
308
+ }
309
+ if (customId.startsWith('variant_retry:')) {
310
+ await handleVariantRetryButton(interaction);
311
+ return;
312
+ }
313
+ if (customId.startsWith('unset_model_retry:')) {
314
+ await handleUnsetModelRetryButton(interaction);
315
+ return;
316
+ }
305
317
  return;
306
318
  }
307
319
  if (interaction.isStringSelectMenu()) {
package/dist/opencode.js CHANGED
@@ -39,7 +39,7 @@ const STARTUP_STDERR_TAIL_LIMIT = 30;
39
39
  const STARTUP_STDERR_LINE_MAX_LENGTH = 120;
40
40
  const STARTUP_ERROR_REASON_MAX_LENGTH = 1500;
41
41
  const ANSI_ESCAPE_REGEX = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
42
- async function requestHealthcheck({ url, }) {
42
+ async function requestHealthcheck({ url, timeoutMs = 2000, }) {
43
43
  return new Promise((resolve, reject) => {
44
44
  const req = http.request(url, {
45
45
  method: 'GET',
@@ -58,6 +58,13 @@ async function requestHealthcheck({ url, }) {
58
58
  });
59
59
  });
60
60
  });
61
+ // Without a timeout, a stalled connection (TCP accepted during opencode
62
+ // startup before the HTTP layer is ready) hangs forever, wedging
63
+ // waitForServer on its first iteration and blocking the entire single
64
+ // server startup promise. Abort and let the poll loop retry instead.
65
+ req.setTimeout(timeoutMs, () => {
66
+ req.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`));
67
+ });
61
68
  req.on('error', reject);
62
69
  req.end();
63
70
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "module": "index.ts",
9
9
  "type": "module",
10
- "version": "0.7.15",
10
+ "version": "0.7.17",
11
11
  "scripts": {
12
12
  "dev": "tsx src/bin.ts",
13
13
  "prepublishOnly": "pnpm build",
@@ -52,8 +52,7 @@
52
52
  "opencode-cached-provider": "workspace:^",
53
53
  "opencode-deterministic-provider": "workspace:^",
54
54
  "prisma": "7.4.2",
55
- "tsx": "^4.20.5",
56
- "undici": "^8.0.2"
55
+ "tsx": "^4.20.5"
57
56
  },
58
57
  "dependencies": {
59
58
  "@ai-sdk/google": "^3.0.53",
@@ -88,6 +87,7 @@
88
87
  "proper-lockfile": "^4.1.2",
89
88
  "string-dedent": "^3.0.2",
90
89
  "traforo": "^0.4.0",
90
+ "undici": "^8.0.2",
91
91
  "ws": "^8.19.0",
92
92
  "xdg-basedir": "^5.1.0",
93
93
  "yaml": "^2.8.3",
@@ -512,6 +512,44 @@ describe('/model', () => {
512
512
  `,
513
513
  )
514
514
  })
515
+
516
+ test('shows provider list error details', async () => {
517
+ const textChannel = { id: 'channel-1', type: ChannelType.GuildText }
518
+ const { command } = createCommand({ channel: textChannel })
519
+
520
+ mockGetOttoMetadata.mockResolvedValue({ projectDirectory: '/repo' })
521
+ mockInitializeOpencodeForDirectory.mockResolvedValue(() => {
522
+ return {
523
+ provider: {
524
+ list: vi.fn(async () => {
525
+ return {
526
+ error: {
527
+ name: 'UnknownError',
528
+ data: {
529
+ message: 'Cursor token refresh failed: {"code":"internal","message":"Error"}',
530
+ },
531
+ },
532
+ }
533
+ }),
534
+ },
535
+ }
536
+ })
537
+
538
+ await handleModelCommand({
539
+ interaction: command as never,
540
+ appId: 'app-1',
541
+ })
542
+
543
+ expect(command.editReply.mock.calls).toMatchInlineSnapshot(`
544
+ [
545
+ [
546
+ {
547
+ "content": "Failed to fetch providers: Cursor token refresh failed: {"code":"internal","message":"Error"}",
548
+ },
549
+ ],
550
+ ]
551
+ `)
552
+ })
515
553
  })
516
554
 
517
555
  describe('/agent', () => {
@@ -34,6 +34,7 @@ import {
34
34
  } from '../opencode.js'
35
35
  import { resolveTextChannel, getOttoMetadata } from '../discord-utils.js'
36
36
  import { createLogger, LogPrefix } from '../logger.js'
37
+ import { formatOpenCodeResponseError } from '../errors.js'
37
38
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
38
39
 
39
40
  const loginLogger = createLogger(LogPrefix.LOGIN)
@@ -266,7 +267,9 @@ export async function handleLoginCommand({
266
267
  })
267
268
 
268
269
  if (!providersResponse.data) {
269
- await interaction.editReply({ content: 'Failed to fetch providers' })
270
+ await interaction.editReply({
271
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
272
+ })
270
273
  return
271
274
  }
272
275
 
@@ -416,7 +419,10 @@ async function handleProviderStep(
416
419
  }
417
420
  const providersResponse = await getClient().provider.list({ directory: ctx.dir })
418
421
  if (!providersResponse.data) {
419
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
422
+ await interaction.editReply({
423
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
424
+ components: [],
425
+ })
420
426
  return
421
427
  }
422
428
  const { all: allProviders, connected } = providersResponse.data
@@ -9,7 +9,10 @@
9
9
  import {
10
10
  ChatInputCommandInteraction,
11
11
  StringSelectMenuInteraction,
12
+ ButtonInteraction,
12
13
  StringSelectMenuBuilder,
14
+ ButtonBuilder,
15
+ ButtonStyle,
13
16
  ActionRowBuilder,
14
17
  ChannelType,
15
18
  type ThreadChannel,
@@ -34,6 +37,7 @@ import {
34
37
  import { getRuntime } from '../session-handler/thread-session-runtime.js'
35
38
  import { getThinkingValuesForModel } from '../thinking-utils.js'
36
39
  import { createLogger, LogPrefix } from '../logger.js'
40
+ import { formatOpenCodeResponseError } from '../errors.js'
37
41
 
38
42
  const logger = createLogger(LogPrefix.MODEL)
39
43
 
@@ -184,7 +188,9 @@ export async function handleModelVariantCommand({
184
188
  }
185
189
 
186
190
  if (!providersResponse.data) {
187
- await interaction.editReply({ content: 'Failed to fetch providers' })
191
+ await interaction.editReply({
192
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
193
+ })
188
194
  return
189
195
  }
190
196
 
@@ -424,22 +430,22 @@ async function applyVariant({
424
430
  `Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`,
425
431
  )
426
432
 
427
- let retried = false
428
- if (context.thread) {
429
- const runtime = getRuntime(context.thread.id)
430
- if (runtime) {
431
- retried = await runtime.retryLastUserPrompt()
432
- }
433
- }
433
+ // Show a button so the user can choose to resend the last message.
434
+ // Do not auto-retry — the user may want to add context in a new message instead.
435
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
436
+ const retryButton = new ButtonBuilder()
437
+ .setCustomId(`variant_retry:${contextHash}`)
438
+ .setLabel('Resend last message')
439
+ .setStyle(ButtonStyle.Secondary)
440
+
441
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
434
442
 
435
- const retryNote = retried
436
- ? '\n_Restarting current request with new variant..._'
437
- : ''
438
443
  await interaction.editReply({
439
- content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
444
+ content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
440
445
  flags: MessageFlags.SuppressEmbeds,
441
- components: [],
446
+ components: [actionRow],
442
447
  })
448
+ return
443
449
  } else if (scope === 'global') {
444
450
  await setGlobalModel({ appId: context.appId, modelId, variant })
445
451
  await setChannelModel({
@@ -474,7 +480,11 @@ async function applyVariant({
474
480
  })
475
481
  }
476
482
 
477
- pendingVariantContexts.delete(contextHash)
483
+ // Clean up the context from memory, except for session scope where the
484
+ // retry button still needs it. TTL handles cleanup in that case.
485
+ if (scope !== 'session') {
486
+ pendingVariantContexts.delete(contextHash)
487
+ }
478
488
  } catch (error) {
479
489
  logger.error('Error applying variant:', error)
480
490
  await interaction.editReply({
@@ -483,3 +493,51 @@ async function applyVariant({
483
493
  })
484
494
  }
485
495
  }
496
+
497
+ /**
498
+ * Handle the "Resend last message" button shown after a session-scope variant change.
499
+ * Aborts any active run and resends the last user prompt with the new variant.
500
+ */
501
+ export async function handleVariantRetryButton(
502
+ interaction: ButtonInteraction,
503
+ ): Promise<void> {
504
+ const customId = interaction.customId
505
+ if (!customId.startsWith('variant_retry:')) {
506
+ return
507
+ }
508
+
509
+ await interaction.deferUpdate()
510
+
511
+ const contextHash = customId.replace('variant_retry:', '')
512
+ const context = pendingVariantContexts.get(contextHash)
513
+
514
+ // Context may have expired (TTL) — still allow the action via runtime alone.
515
+ const threadId = context?.thread?.id
516
+
517
+ if (!threadId) {
518
+ await interaction.editReply({
519
+ content: 'Could not find the session thread. Please resend your message manually.',
520
+ components: [],
521
+ })
522
+ return
523
+ }
524
+
525
+ const runtime = getRuntime(threadId)
526
+ if (!runtime) {
527
+ await interaction.editReply({
528
+ content: 'No active session found. Please resend your message manually.',
529
+ components: [],
530
+ })
531
+ return
532
+ }
533
+
534
+ const retried = await runtime.retryLastUserPrompt()
535
+ pendingVariantContexts.delete(contextHash)
536
+
537
+ await interaction.editReply({
538
+ content: retried
539
+ ? `Restarting with new variant...`
540
+ : `Variant changed. Send a new message to continue.`,
541
+ components: [],
542
+ })
543
+ }
@@ -3,7 +3,10 @@
3
3
  import {
4
4
  ChatInputCommandInteraction,
5
5
  StringSelectMenuInteraction,
6
+ ButtonInteraction,
6
7
  StringSelectMenuBuilder,
8
+ ButtonBuilder,
9
+ ButtonStyle,
7
10
  ActionRowBuilder,
8
11
  ChannelType,
9
12
  type ThreadChannel,
@@ -30,6 +33,7 @@ import { getDefaultModel } from '../session-handler/model-utils.js'
30
33
  import { getRuntime } from '../session-handler/thread-session-runtime.js'
31
34
  import { getThinkingValuesForModel } from '../thinking-utils.js'
32
35
  import { createLogger, LogPrefix } from '../logger.js'
36
+ import { formatOpenCodeResponseError } from '../errors.js'
33
37
  import * as errore from 'errore'
34
38
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
35
39
 
@@ -447,7 +451,7 @@ export async function handleModelCommand({
447
451
 
448
452
  if (!providersResponse.data) {
449
453
  await interaction.editReply({
450
- content: 'Failed to fetch providers',
454
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
451
455
  })
452
456
  return
453
457
  }
@@ -596,7 +600,10 @@ export async function handleProviderSelectMenu(
596
600
  }
597
601
  const providersResponse = await getClient().provider.list({ directory: context.dir })
598
602
  if (!providersResponse.data) {
599
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
603
+ await interaction.editReply({
604
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
605
+ components: [],
606
+ })
600
607
  return
601
608
  }
602
609
  const { all: allProviders, connected } = providersResponse.data
@@ -640,7 +647,7 @@ export async function handleProviderSelectMenu(
640
647
 
641
648
  if (!providersResponse.data) {
642
649
  await interaction.editReply({
643
- content: 'Failed to fetch providers',
650
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
644
651
  components: [],
645
652
  })
646
653
  return
@@ -1016,22 +1023,22 @@ export async function handleModelScopeSelectMenu(
1016
1023
  `Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
1017
1024
  )
1018
1025
 
1019
- let retried = false
1020
- if (context.thread) {
1021
- const runtime = getRuntime(context.thread.id)
1022
- if (runtime) {
1023
- retried = await runtime.retryLastUserPrompt()
1024
- }
1025
- }
1026
+ // Show a button so the user can choose to resend the last message.
1027
+ // Do not auto-retry — the user may want to add context in a new message instead.
1028
+ // Context is kept alive (TTL handles cleanup) so the button can access it.
1029
+ const retryButton = new ButtonBuilder()
1030
+ .setCustomId(`model_retry:${contextHash}`)
1031
+ .setLabel('Resend last message')
1032
+ .setStyle(ButtonStyle.Secondary)
1033
+
1034
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
1026
1035
 
1027
- const retryNote = retried
1028
- ? '\n_Restarting current request with new model..._'
1029
- : ''
1030
1036
  await interaction.editReply({
1031
- content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
1037
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
1032
1038
  flags: MessageFlags.SuppressEmbeds,
1033
- components: [],
1039
+ components: [actionRow],
1034
1040
  })
1041
+ return
1035
1042
  } else if (selectedScope === 'global') {
1036
1043
  if (!context.appId) {
1037
1044
  deleteModelContext(contextHash)
@@ -1066,8 +1073,11 @@ export async function handleModelScopeSelectMenu(
1066
1073
  })
1067
1074
  }
1068
1075
 
1069
- // Clean up the context from memory
1070
- deleteModelContext(contextHash)
1076
+ // Clean up the context from memory, except for session scope where the
1077
+ // retry button still needs it. TTL handles cleanup in that case.
1078
+ if (selectedScope !== 'session') {
1079
+ deleteModelContext(contextHash)
1080
+ }
1071
1081
  } catch (error) {
1072
1082
  modelLogger.error('Error saving model preference:', error)
1073
1083
  await interaction.editReply({
@@ -1076,3 +1086,51 @@ export async function handleModelScopeSelectMenu(
1076
1086
  })
1077
1087
  }
1078
1088
  }
1089
+
1090
+ /**
1091
+ * Handle the "Resend last message" button shown after a session-scope model change.
1092
+ * Aborts any active run and resends the last user prompt with the new model.
1093
+ */
1094
+ export async function handleModelRetryButton(
1095
+ interaction: ButtonInteraction,
1096
+ ): Promise<void> {
1097
+ const customId = interaction.customId
1098
+ if (!customId.startsWith('model_retry:')) {
1099
+ return
1100
+ }
1101
+
1102
+ await interaction.deferUpdate()
1103
+
1104
+ const contextHash = customId.replace('model_retry:', '')
1105
+ const context = pendingModelContexts.get(contextHash)
1106
+
1107
+ // Context may have expired (10 min TTL) — still allow the action via runtime alone.
1108
+ const threadId = context?.thread?.id
1109
+
1110
+ if (!threadId) {
1111
+ await interaction.editReply({
1112
+ content: 'Could not find the session thread. Please resend your message manually.',
1113
+ components: [],
1114
+ })
1115
+ return
1116
+ }
1117
+
1118
+ const runtime = getRuntime(threadId)
1119
+ if (!runtime) {
1120
+ await interaction.editReply({
1121
+ content: 'No active session found. Please resend your message manually.',
1122
+ components: [],
1123
+ })
1124
+ return
1125
+ }
1126
+
1127
+ const retried = await runtime.retryLastUserPrompt()
1128
+ deleteModelContext(contextHash)
1129
+
1130
+ await interaction.editReply({
1131
+ content: retried
1132
+ ? `Restarting with new model...`
1133
+ : `Model changed. Send a new message to continue.`,
1134
+ components: [],
1135
+ })
1136
+ }
@@ -2,6 +2,10 @@
2
2
 
3
3
  import {
4
4
  ChatInputCommandInteraction,
5
+ ButtonInteraction,
6
+ ButtonBuilder,
7
+ ButtonStyle,
8
+ ActionRowBuilder,
5
9
  ChannelType,
6
10
  type ThreadChannel,
7
11
  type TextChannel,
@@ -154,21 +158,69 @@ export async function handleUnsetModelCommand({
154
158
  : `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`
155
159
  }
156
160
 
157
- // Check if there's a running request and abort+retry with new model (only for session changes in threads)
158
- let retried = false
161
+ const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
162
+ // Show a button so the user can choose to resend the last message (session scope only).
163
+ // Do not auto-retry — the user may want to add context in a new message instead.
159
164
  if (isThread && clearedType === 'session' && sessionId) {
160
165
  const runtime = getRuntime(channel.id)
161
166
  if (runtime) {
162
- retried = await runtime.retryLastUserPrompt()
167
+ const retryButton = new ButtonBuilder()
168
+ .setCustomId(`unset_model_retry:${channel.id}`)
169
+ .setLabel('Resend last message')
170
+ .setStyle(ButtonStyle.Secondary)
171
+
172
+ const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
173
+
174
+ await interaction.editReply({
175
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
176
+ components: [actionRow],
177
+ })
178
+ return
163
179
  }
164
180
  }
165
181
 
166
- const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
167
- const retriedText = retried
168
- ? '\n_Restarting current request with new model..._'
169
- : ''
182
+ await interaction.editReply({
183
+ content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Handle the "Resend last message" button after clearing a session model override.
189
+ */
190
+ export async function handleUnsetModelRetryButton(
191
+ interaction: ButtonInteraction,
192
+ ): Promise<void> {
193
+ const customId = interaction.customId
194
+ if (!customId.startsWith('unset_model_retry:')) {
195
+ return
196
+ }
197
+
198
+ await interaction.deferUpdate()
199
+
200
+ const threadId = customId.replace('unset_model_retry:', '')
201
+ if (!threadId) {
202
+ await interaction.editReply({
203
+ content: 'Could not find the session. Please resend your message manually.',
204
+ components: [],
205
+ })
206
+ return
207
+ }
208
+
209
+ const runtime = getRuntime(threadId)
210
+ if (!runtime) {
211
+ await interaction.editReply({
212
+ content: 'No active session found. Please resend your message manually.',
213
+ components: [],
214
+ })
215
+ return
216
+ }
217
+
218
+ const retried = await runtime.retryLastUserPrompt()
170
219
 
171
220
  await interaction.editReply({
172
- content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}${retriedText}`,
221
+ content: retried
222
+ ? 'Restarting with new model...'
223
+ : 'Model changed. Send a new message to continue.',
224
+ components: [],
173
225
  })
174
226
  }
@@ -130,11 +130,13 @@ import * as errore from "errore";
130
130
  import { createLogger, formatErrorWithStack, LogPrefix } from "./logger.js";
131
131
  import { writeHeapSnapshot, startHeapMonitor } from "./heap-monitor.js";
132
132
  import { startTaskRunner } from "./task-runner.js";
133
+ import { setGlobalDispatcher, Agent } from "undici";
133
134
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
134
135
  // Each session's event.subscribe() holds a connection; without enough connections,
135
136
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
136
- // undici is a transitive dep from discord.js — not listed in our package.json.
137
- // Types are declared in src/undici.d.ts.
137
+ setGlobalDispatcher(
138
+ new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }),
139
+ );
138
140
 
139
141
  const discordLogger = createLogger(LogPrefix.DISCORD);
140
142
  const voiceLogger = createLogger(LogPrefix.VOICE);
package/src/errors.ts CHANGED
@@ -168,6 +168,37 @@ export class GitCommandError extends createTaggedError({
168
168
  message: 'Git command failed: $command',
169
169
  }) {}
170
170
 
171
+ export function formatOpenCodeResponseError(error: unknown): string {
172
+ if (error && typeof error === 'object') {
173
+ if (
174
+ 'data' in error &&
175
+ error.data &&
176
+ typeof error.data === 'object' &&
177
+ 'message' in error.data
178
+ ) {
179
+ return String(error.data.message)
180
+ }
181
+
182
+ if (
183
+ 'errors' in error &&
184
+ Array.isArray(error.errors) &&
185
+ error.errors.length > 0
186
+ ) {
187
+ return JSON.stringify(error.errors)
188
+ }
189
+
190
+ if ('message' in error && typeof error.message === 'string') {
191
+ return error.message
192
+ }
193
+
194
+ if ('name' in error && typeof error.name === 'string') {
195
+ return error.name
196
+ }
197
+ }
198
+
199
+ return 'Unknown OpenCode API error'
200
+ }
201
+
171
202
  // ═══════════════════════════════════════════════════════════════════════════
172
203
  // UNION TYPES - For function signatures
173
204
  // ═══════════════════════════════════════════════════════════════════════════
@@ -57,8 +57,12 @@ import {
57
57
  handleProviderSelectMenu,
58
58
  handleModelSelectMenu,
59
59
  handleModelScopeSelectMenu,
60
+ handleModelRetryButton,
60
61
  } from './commands/model.js'
61
- import { handleUnsetModelCommand } from './commands/unset-model.js'
62
+ import {
63
+ handleUnsetModelCommand,
64
+ handleUnsetModelRetryButton,
65
+ } from './commands/unset-model.js'
62
66
  import {
63
67
  handleLoginCommand,
64
68
  handleLoginSelect,
@@ -116,6 +120,7 @@ import {
116
120
  handleModelVariantCommand,
117
121
  handleVariantQuickSelectMenu,
118
122
  handleVariantScopeSelectMenu,
123
+ handleVariantRetryButton,
119
124
  } from './commands/model-variant.js'
120
125
  import { hasOttoBotPermission } from './discord-utils.js'
121
126
  import { createLogger, LogPrefix } from './logger.js'
@@ -463,6 +468,21 @@ export function registerInteractionHandler({
463
468
  return
464
469
  }
465
470
 
471
+ if (customId.startsWith('model_retry:')) {
472
+ await handleModelRetryButton(interaction)
473
+ return
474
+ }
475
+
476
+ if (customId.startsWith('variant_retry:')) {
477
+ await handleVariantRetryButton(interaction)
478
+ return
479
+ }
480
+
481
+ if (customId.startsWith('unset_model_retry:')) {
482
+ await handleUnsetModelRetryButton(interaction)
483
+ return
484
+ }
485
+
466
486
  return
467
487
  }
468
488
 
package/src/opencode.ts CHANGED
@@ -82,8 +82,10 @@ const ANSI_ESCAPE_REGEX =
82
82
 
83
83
  async function requestHealthcheck({
84
84
  url,
85
+ timeoutMs = 2000,
85
86
  }: {
86
87
  url: string
88
+ timeoutMs?: number
87
89
  }): Promise<{ status: number; body: string }> {
88
90
  return new Promise((resolve, reject) => {
89
91
  const req = http.request(
@@ -107,6 +109,13 @@ async function requestHealthcheck({
107
109
  })
108
110
  },
109
111
  )
112
+ // Without a timeout, a stalled connection (TCP accepted during opencode
113
+ // startup before the HTTP layer is ready) hangs forever, wedging
114
+ // waitForServer on its first iteration and blocking the entire single
115
+ // server startup promise. Abort and let the poll loop retry instead.
116
+ req.setTimeout(timeoutMs, () => {
117
+ req.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`))
118
+ })
110
119
  req.on('error', reject)
111
120
  req.end()
112
121
  })