kimaki 0.4.83 → 0.4.84

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 CHANGED
@@ -26,7 +26,7 @@ import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './lo
26
26
  import { initSentry, notifyError } from './sentry.js';
27
27
  import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
28
28
  import { spawn, execSync } from 'node:child_process';
29
- import { setDataDir, getDataDir, getProjectsDir, } from './config.js';
29
+ import { setDataDir, setProjectsDir, getDataDir, getProjectsDir, } from './config.js';
30
30
  import { execAsync } from './worktrees.js';
31
31
  import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
32
32
  import { startHranaServer } from './hrana-server.js';
@@ -1305,6 +1305,7 @@ cli
1305
1305
  .option('--restart-onboarding', 'Prompt for new credentials even if saved')
1306
1306
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
1307
1307
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1308
+ .option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
1308
1309
  .option('--install-url', 'Print the bot install URL and exit')
1309
1310
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1310
1311
  .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
@@ -1332,6 +1333,10 @@ cli
1332
1333
  setDataDir(options.dataDir);
1333
1334
  cliLogger.log(`Using data directory: ${getDataDir()}`);
1334
1335
  }
1336
+ if (options.projectsDir) {
1337
+ setProjectsDir(options.projectsDir);
1338
+ cliLogger.log(`Using projects directory: ${getProjectsDir()}`);
1339
+ }
1335
1340
  // Initialize file logging to <dataDir>/kimaki.log
1336
1341
  initLogFile(getDataDir());
1337
1342
  // Batch all CLI flag store updates into a single setState call.
@@ -2623,6 +2628,7 @@ cli
2623
2628
  .option('-t, --tunnel-id [id]', 'Custom tunnel ID (only for services safe to expose publicly; prefer random default)')
2624
2629
  .option('-h, --host [host]', 'Local host (default: localhost)')
2625
2630
  .option('-s, --server [url]', 'Tunnel server URL')
2631
+ .option('-k, --kill', 'Kill any existing process on the port before starting')
2626
2632
  .action(async (options) => {
2627
2633
  const { runTunnel, parseCommandFromArgv, CLI_NAME } = await import('traforo/run-tunnel');
2628
2634
  if (!options.port) {
@@ -2644,10 +2650,11 @@ cli
2644
2650
  baseDomain: 'kimaki.xyz',
2645
2651
  serverUrl: options.server,
2646
2652
  command: command.length > 0 ? command : undefined,
2653
+ kill: options.kill,
2647
2654
  });
2648
2655
  });
2649
2656
  cli
2650
- .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 1 hour. Runs until Ctrl+C. Use tmux to run in background.')
2657
+ .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. Use tmux to run in background.')
2651
2658
  .action(async () => {
2652
2659
  const { startScreenshare } = await import('./commands/screenshare.js');
2653
2660
  try {
@@ -15,11 +15,14 @@ import { startWebsockify } from '../websockify.js';
15
15
  import { createLogger } from '../logger.js';
16
16
  import { execAsync } from '../worktrees.js';
17
17
  const logger = createLogger('SCREEN');
18
+ const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS;
18
19
  /** One active screenshare per guild (Discord) or per machine (CLI) */
19
20
  const activeSessions = new Map();
20
21
  const VNC_PORT = 5900;
21
- const MAX_SESSION_MS = 60 * 60 * 1000; // 1 hour
22
+ const MAX_SESSION_MINUTES = 30;
23
+ const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000;
22
24
  const TUNNEL_BASE_DOMAIN = 'kimaki.xyz';
25
+ const SCREENSHARE_TUNNEL_ID_BYTES = 16;
23
26
  // Public noVNC client — we point it at our tunnel URL
24
27
  export function buildNoVncUrl({ tunnelHost }) {
25
28
  const params = new URLSearchParams({
@@ -32,6 +35,9 @@ export function buildNoVncUrl({ tunnelHost }) {
32
35
  });
33
36
  return `https://novnc.com/noVNC/vnc.html?${params.toString()}`;
34
37
  }
38
+ export function createScreenshareTunnelId() {
39
+ return crypto.randomBytes(SCREENSHARE_TUNNEL_ID_BYTES).toString('hex');
40
+ }
35
41
  // macOS has two separate services:
36
42
  // - "Screen Sharing" = view-only VNC (com.apple.screensharing)
37
43
  // - "Remote Management" = full control VNC with mouse/keyboard (ARDAgent)
@@ -170,7 +176,7 @@ export async function startScreenshare({ sessionKey, startedBy, }) {
170
176
  throw err;
171
177
  }
172
178
  // Step 3: create tunnel
173
- const tunnelId = crypto.randomBytes(8).toString('hex');
179
+ const tunnelId = createScreenshareTunnelId();
174
180
  const tunnelClient = new TunnelClient({
175
181
  localPort: wsInstance.port,
176
182
  tunnelId,
@@ -197,9 +203,9 @@ export async function startScreenshare({ sessionKey, startedBy, }) {
197
203
  const tunnelHost = `${tunnelId}-tunnel.${TUNNEL_BASE_DOMAIN}`;
198
204
  const tunnelUrl = `https://${tunnelHost}`;
199
205
  const noVncUrl = buildNoVncUrl({ tunnelHost });
200
- // Auto-kill after 1 hour
206
+ // Auto-kill after a short session so a leaked URL does not stay usable all day.
201
207
  const timeoutTimer = setTimeout(() => {
202
- logger.log(`Screen share auto-stopped after 1 hour (key: ${sessionKey})`);
208
+ logger.log(`Screen share auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`);
203
209
  stopScreenshare({ sessionKey });
204
210
  }, MAX_SESSION_MS);
205
211
  // Don't keep the process alive just for this timer
@@ -240,14 +246,16 @@ export async function handleScreenshareCommand({ command, }) {
240
246
  });
241
247
  return;
242
248
  }
243
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
249
+ await command.deferReply({ flags: SECURE_REPLY_FLAGS });
244
250
  try {
245
251
  const session = await startScreenshare({
246
252
  sessionKey: guildId,
247
253
  startedBy: command.user.tag,
248
254
  });
249
255
  await command.editReply({
250
- content: `Screen sharing started\n${session.noVncUrl}`,
256
+ content: `Screen sharing started. This reply is private and the URL uses a high-entropy tunnel id. ` +
257
+ `It will auto-stop after ${MAX_SESSION_MINUTES} minutes. Use /screenshare-stop to stop sooner.\n` +
258
+ `${session.noVncUrl}`,
251
259
  });
252
260
  }
253
261
  catch (err) {
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildNoVncUrl, createScreenshareTunnelId } from './screenshare.js';
3
+ describe('screenshare security defaults', () => {
4
+ test('generates a 128-bit tunnel id', () => {
5
+ const ids = new Set(Array.from({ length: 32 }, () => {
6
+ return createScreenshareTunnelId();
7
+ }));
8
+ expect(ids.size).toBe(32);
9
+ for (const id of ids) {
10
+ expect(id).toMatch(/^[0-9a-f]{32}$/);
11
+ }
12
+ });
13
+ test('builds a secure noVNC URL', () => {
14
+ const url = new URL(buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.xyz' }));
15
+ expect(url.origin).toBe('https://novnc.com');
16
+ expect(url.searchParams.get('host')).toBe('0123456789abcdef-tunnel.kimaki.xyz');
17
+ expect(url.searchParams.get('port')).toBe('443');
18
+ expect(url.searchParams.get('encrypt')).toBe('1');
19
+ });
20
+ });
package/dist/config.js CHANGED
@@ -42,11 +42,26 @@ export function setDataDir(dir) {
42
42
  }
43
43
  /**
44
44
  * Get the projects directory path (for /create-new-project command).
45
- * Returns <dataDir>/projects
45
+ * Returns the custom --projects-dir if set, otherwise <dataDir>/projects.
46
46
  */
47
47
  export function getProjectsDir() {
48
+ const custom = store.getState().projectsDir;
49
+ if (custom) {
50
+ return custom;
51
+ }
48
52
  return path.join(getDataDir(), 'projects');
49
53
  }
54
+ /**
55
+ * Set a custom projects directory path (from --projects-dir CLI flag).
56
+ * Creates the directory if it doesn't exist.
57
+ */
58
+ export function setProjectsDir(dir) {
59
+ const resolvedDir = path.resolve(dir);
60
+ if (!fs.existsSync(resolvedDir)) {
61
+ fs.mkdirSync(resolvedDir, { recursive: true });
62
+ }
63
+ store.setState({ projectsDir: resolvedDir });
64
+ }
50
65
  const DEFAULT_LOCK_PORT = 29988;
51
66
  /**
52
67
  * Derive a lock port from the data directory path.
@@ -77,8 +77,6 @@ function describeCloseCode(code) {
77
77
  return codes[code] || 'unknown';
78
78
  }
79
79
  const shardReconnectState = new Map();
80
- const shardReconnectRecoveryTimeouts = new Map();
81
- const GATEWAY_RELOGIN_GRACE_MS = 10_000;
82
80
  function getOrCreateShardState(shardId) {
83
81
  let state = shardReconnectState.get(shardId);
84
82
  if (!state) {
@@ -144,46 +142,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
144
142
  discordClient = await createDiscordClient();
145
143
  }
146
144
  let currentAppId = appId;
147
- let runtimeHandlersRegistered = false;
148
- let gatewayReloginInFlight = false;
149
- const clearShardRecoveryTimeout = ({ shardId }) => {
150
- const timeout = shardReconnectRecoveryTimeouts.get(shardId);
151
- if (!timeout) {
152
- return;
153
- }
154
- clearTimeout(timeout);
155
- shardReconnectRecoveryTimeouts.delete(shardId);
156
- };
157
- const forceGatewayRelogin = ({ shardId }) => {
158
- if (gatewayReloginInFlight) {
159
- return;
160
- }
161
- gatewayReloginInFlight = true;
162
- void (async () => {
163
- discordLogger.warn(`[GATEWAY] Shard ${shardId} stayed reconnecting for ${GATEWAY_RELOGIN_GRACE_MS}ms, forcing client relogin`);
164
- try {
165
- discordClient.destroy();
166
- await discordClient.login(token);
167
- }
168
- catch (error) {
169
- discordLogger.error(`[GATEWAY] Forced relogin failed: ${formatErrorWithStack(error)}`);
170
- }
171
- finally {
172
- gatewayReloginInFlight = false;
173
- }
174
- })();
175
- };
176
- const scheduleShardRecoveryTimeout = ({ shardId }) => {
177
- clearShardRecoveryTimeout({ shardId });
178
- const timeout = setTimeout(() => {
179
- const state = shardReconnectState.get(shardId);
180
- if (!state?.attempts) {
181
- return;
182
- }
183
- forceGatewayRelogin({ shardId });
184
- }, GATEWAY_RELOGIN_GRACE_MS);
185
- shardReconnectRecoveryTimeouts.set(shardId, timeout);
186
- };
187
145
  const setupHandlers = async (c) => {
188
146
  discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
189
147
  discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
@@ -202,12 +160,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
202
160
  }
203
161
  voiceLogger.log('[READY] Bot is ready');
204
162
  markDiscordGatewayReady();
205
- if (!runtimeHandlersRegistered) {
206
- registerInteractionHandler({ discordClient: c, appId: currentAppId });
207
- registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
208
- startExternalOpencodeSessionSync({ discordClient: c });
209
- runtimeHandlersRegistered = true;
210
- }
163
+ registerInteractionHandler({ discordClient: c, appId: currentAppId });
164
+ registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
165
+ startExternalOpencodeSessionSync({ discordClient: c });
211
166
  // Channel logging is informational only; do it in background so startup stays responsive.
212
167
  void (async () => {
213
168
  for (const guild of c.guilds.cache.values()) {
@@ -226,14 +181,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
226
181
  };
227
182
  // If client is already ready (was logged in before being passed to us),
228
183
  // run setup immediately. Otherwise wait for the ClientReady event.
229
- discordClient.on(Events.ClientReady, (readyClient) => {
230
- void setupHandlers(readyClient).catch((error) => {
231
- discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
232
- });
233
- });
234
184
  if (discordClient.isReady()) {
235
185
  await setupHandlers(discordClient);
236
186
  }
187
+ else {
188
+ discordClient.once(Events.ClientReady, setupHandlers);
189
+ }
237
190
  discordClient.on(Events.Error, (error) => {
238
191
  discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
239
192
  });
@@ -262,10 +215,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
262
215
  parts.push(`last error: ${state.lastError.message}`);
263
216
  }
264
217
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
265
- scheduleShardRecoveryTimeout({ shardId });
266
218
  });
267
219
  discordClient.on(Events.ShardResume, (shardId, replayedEvents) => {
268
- clearShardRecoveryTimeout({ shardId });
269
220
  const state = shardReconnectState.get(shardId);
270
221
  if (state?.attempts) {
271
222
  discordLogger.log(`[GATEWAY] Shard ${shardId} resumed after ${state.attempts} reconnect attempt(s), ${replayedEvents} replayed events`);
@@ -279,7 +230,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
279
230
  // After a gateway proxy redeploy, sessions are lost (in-memory), so RESUME
280
231
  // fails with INVALID_SESSION and discord.js falls back to fresh IDENTIFY.
281
232
  discordClient.on(Events.ShardReady, (shardId) => {
282
- clearShardRecoveryTimeout({ shardId });
283
233
  const state = shardReconnectState.get(shardId);
284
234
  if (state?.attempts) {
285
235
  discordLogger.log(`[GATEWAY] Shard ${shardId} ready after ${state.attempts} reconnect attempt(s)`);
@@ -287,9 +237,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
287
237
  shardReconnectState.delete(shardId);
288
238
  });
289
239
  discordClient.on(Events.Invalidated, () => {
290
- for (const shardId of shardReconnectRecoveryTimeouts.keys()) {
291
- clearShardRecoveryTimeout({ shardId });
292
- }
293
240
  discordLogger.error('[GATEWAY] Session invalidated by Discord');
294
241
  });
295
242
  discordClient.on(Events.MessageCreate, async (message) => {
@@ -646,6 +593,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
646
593
  prompt: '',
647
594
  userId: message.author.id,
648
595
  username: message.member?.displayName || message.author.displayName,
596
+ sourceMessageId: message.id,
597
+ sourceThreadId: thread.id,
649
598
  appId: currentAppId,
650
599
  preprocess: () => {
651
600
  return preprocessNewThreadMessage({
@@ -360,7 +360,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
360
360
  .toJSON(),
361
361
  new SlashCommandBuilder()
362
362
  .setName('screenshare')
363
- .setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)'))
363
+ .setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 30 minutes)'))
364
364
  .setDMPermission(false)
365
365
  .toJSON(),
366
366
  new SlashCommandBuilder()
@@ -31,13 +31,12 @@ function parseDiscordOriginMetadata(text) {
31
31
  acc[key] = value || '';
32
32
  return acc;
33
33
  }, {});
34
- const messageId = attrs['message-id'];
35
34
  const username = attrs['name'];
36
- if (!messageId || !username) {
35
+ if (!username) {
37
36
  return null;
38
37
  }
39
38
  return {
40
- messageId,
39
+ messageId: attrs['message-id'] || undefined,
41
40
  username,
42
41
  threadId: attrs['thread-id'] || undefined,
43
42
  };
@@ -81,14 +80,13 @@ function getRenderableUserTextParts({ message, }) {
81
80
  function getExternalUserMirrorText({ username, prompt, }) {
82
81
  return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
83
82
  }
84
- // Pure derivation: does the latest user turn come from outside this
85
- // Discord thread and contain parts we haven't mirrored yet?
86
- // Used to reclaim sync for kimaki-owned threads when the user resumes
87
- // from the OpenCode CLI/TUI side. No new state derives from existing
88
- // part_messages dedupe set and <discord-user /> origin tags.
89
- function hasExternalResume({ messages, threadId, syncedPartIds, }) {
90
- // Walk messages newest-first to find the latest user message
91
- // with renderable text content.
83
+ // Pure derivation: is the latest user turn from Discord?
84
+ // Checks the newest user message with renderable text for a <discord-user />
85
+ // synthetic part. If present, the session is currently driven from Discord
86
+ // (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
87
+ // external sync should mirror it this naturally handles the "reclaim" case
88
+ // (external discord external) without any DB source toggling.
89
+ function isLatestUserTurnFromDiscord({ messages, }) {
92
90
  for (let i = messages.length - 1; i >= 0; i--) {
93
91
  const message = messages[i];
94
92
  if (message.info.role !== 'user') {
@@ -99,18 +97,10 @@ function hasExternalResume({ messages, threadId, syncedPartIds, }) {
99
97
  continue;
100
98
  }
101
99
  // Found the latest user message with actual text content.
102
- // Check if it originated from this Discord thread.
103
- const origin = getDiscordOriginMetadataFromMessage({ message });
104
- if (origin && (!origin.threadId || origin.threadId === threadId)) {
105
- // Latest user turn came from Discord — no external resume.
106
- return false;
107
- }
108
- // Latest user turn is external (CLI/TUI). Check if we already
109
- // mirrored all its parts. If any part is unseen, reclaim.
110
- return renderableParts.some((p) => {
111
- return !syncedPartIds.has(p.id);
112
- });
100
+ // If it has <discord-user /> origin metadata, it came from Discord.
101
+ return getDiscordOriginMetadataFromMessage({ message }) !== null;
113
102
  }
103
+ // No user messages with text — treat as external (allow sync).
114
104
  return false;
115
105
  }
116
106
  function shouldMirrorAssistantPart({ part, verbosity, }) {
@@ -177,16 +167,14 @@ function groupTrackedChannelsByDirectory(trackedChannels) {
177
167
  }, new Map());
178
168
  return [...grouped.values()];
179
169
  }
180
- async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages, reclaimable, }) {
170
+ async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages, }) {
181
171
  const existingThreadId = await getThreadIdBySessionId(sessionId);
182
172
  if (existingThreadId) {
173
+ // Caller already verified via isLatestUserTurnFromDiscord that this
174
+ // session should be synced. If the thread was kimaki-owned, flip it
175
+ // to external_poll so typing and future polls work naturally.
183
176
  const existingSource = await getThreadSessionSource(existingThreadId);
184
- if (existingSource && existingSource !== 'external_poll' && !reclaimable) {
185
- return null;
186
- }
187
- // Reclaim: flip kimaki-owned thread back to external_poll so typing
188
- // and future polls work naturally without any new stored state.
189
- if (existingSource === 'kimaki' && reclaimable) {
177
+ if (existingSource === 'kimaki') {
190
178
  await upsertThreadSession({
191
179
  threadId: existingThreadId,
192
180
  sessionId,
@@ -250,14 +238,16 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
250
238
  if (unsyncedParts.length === 0) {
251
239
  continue;
252
240
  }
253
- // If the user message came from this Discord thread, record the
254
- // mapping to the original Discord message without sending a new one.
241
+ // If the user message came from this Discord thread, skip mirroring
242
+ // it's already visible. When message-id is available, record a
243
+ // direct mapping for part dedup. When it's missing (sourceMessageId
244
+ // is optional in IngressInput), just mark parts as synced.
255
245
  const discordOrigin = getDiscordOriginMetadataFromMessage({ message });
256
246
  if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
257
247
  unsyncedParts.forEach((part) => {
258
248
  directMappings.push({
259
249
  partId: part.id,
260
- messageId: discordOrigin.messageId,
250
+ messageId: discordOrigin.messageId || '',
261
251
  threadId: thread.id,
262
252
  });
263
253
  syncedPartIds.add(part.id);
@@ -312,24 +302,12 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
312
302
  throw messagesResponse;
313
303
  }
314
304
  const messages = messagesResponse.data || [];
315
- // Pre-check: for kimaki-owned threads, derive whether the user resumed
316
- // from the OpenCode CLI/TUI by inspecting the latest user turn and
317
- // existing part_messages. No new state pure derivation from evidence.
318
- const existingThreadId = await getThreadIdBySessionId(sessionId);
319
- let reclaimable = false;
320
- if (existingThreadId) {
321
- const existingSource = await getThreadSessionSource(existingThreadId);
322
- if (existingSource === 'kimaki') {
323
- const existingPartIds = await getPartMessageIds(existingThreadId);
324
- reclaimable = hasExternalResume({
325
- messages,
326
- threadId: existingThreadId,
327
- syncedPartIds: new Set(existingPartIds),
328
- });
329
- if (!reclaimable) {
330
- return;
331
- }
332
- }
305
+ // Pure derivation from opencode events: if the latest user turn has
306
+ // <discord-user /> metadata, kimaki's thread runtime owns this session.
307
+ // Skip external sync entirely. When the user resumes from CLI/TUI the
308
+ // latest user turn will lack the tag, so sync picks it up naturally.
309
+ if (isLatestUserTurnFromDiscord({ messages })) {
310
+ return;
333
311
  }
334
312
  const thread = await ensureExternalSessionThread({
335
313
  discordClient,
@@ -337,7 +315,6 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
337
315
  sessionId,
338
316
  sessionTitle,
339
317
  messages,
340
- reclaimable,
341
318
  });
342
319
  if (thread === null) {
343
320
  return;
@@ -534,5 +511,5 @@ export const externalOpencodeSyncInternals = {
534
511
  sortSessionsByRecency,
535
512
  parseDiscordOriginMetadata,
536
513
  getDiscordOriginMetadataFromMessage,
537
- hasExternalResume,
514
+ isLatestUserTurnFromDiscord,
538
515
  };
@@ -98,13 +98,6 @@ function createMatchers() {
98
98
  };
99
99
  return [defaultReply];
100
100
  }
101
- function waitForChildExit(child) {
102
- return new Promise((resolve) => {
103
- child.once('exit', () => {
104
- resolve();
105
- });
106
- });
107
- }
108
101
  async function waitForProxyReady({ port, timeoutMs = 30_000, }) {
109
102
  const start = Date.now();
110
103
  while (Date.now() - start < timeoutMs) {
@@ -334,7 +327,7 @@ describeIf('gateway-proxy e2e', () => {
334
327
  `);
335
328
  expect(reply).toBeDefined();
336
329
  expect(reply.content.trim().length).toBeGreaterThan(0);
337
- }, 30_000);
330
+ }, 15_000);
338
331
  test('follow-up message in thread gets bot reply', async () => {
339
332
  const existingMessages = await discord.thread(firstThreadId).getMessages();
340
333
  const existingIds = new Set(existingMessages.map((m) => m.id));
@@ -347,7 +340,7 @@ describeIf('gateway-proxy e2e', () => {
347
340
  await waitForFooterMessage({
348
341
  discord,
349
342
  threadId: firstThreadId,
350
- timeout: 15_000,
343
+ timeout: 4_000,
351
344
  afterMessageIncludes: 'follow up through proxy',
352
345
  afterAuthorId: TEST_USER_ID,
353
346
  });
@@ -365,51 +358,10 @@ describeIf('gateway-proxy e2e', () => {
365
358
  `);
366
359
  expect(reply).toBeDefined();
367
360
  expect(reply.content.trim().length).toBeGreaterThan(0);
368
- }, 30_000);
369
- test('bot recovers after gateway proxy restart', async () => {
370
- const exitPromise = waitForChildExit(proxyProcess);
371
- proxyProcess.kill('SIGTERM');
372
- await exitPromise;
373
- const restartedProxy = startGatewayProxy({
374
- configDir: path.join(directories.dataDir, 'proxy'),
375
- port: proxyPort,
376
- twinPort: discord.port,
377
- botToken: discord.botToken,
378
- gatewayUrl: discord.gatewayUrl,
379
- });
380
- proxyProcess = restartedProxy.process;
381
- await waitForProxyReady({ port: proxyPort, timeoutMs: 30_000 });
382
- await new Promise((resolve) => {
383
- setTimeout(resolve, 6_000);
384
- });
385
- await discord.channel(CHANNEL_1_ID).user(TEST_USER_ID).sendMessage({
386
- content: 'recovered after proxy restart',
387
- });
388
- const recoveryThread = await discord.channel(CHANNEL_1_ID).waitForThread({
389
- timeout: 30_000,
390
- predicate: (t) => {
391
- return t.name?.includes('recovered after proxy restart') ?? false;
392
- },
393
- });
394
- const reply = await discord.thread(recoveryThread.id).waitForBotReply({
395
- timeout: 30_000,
396
- });
397
- await waitForFooterMessage({
398
- discord,
399
- threadId: recoveryThread.id,
400
- timeout: 30_000,
401
- afterMessageIncludes: 'recovered after proxy restart',
402
- afterAuthorId: TEST_USER_ID,
403
- });
404
- expect(await discord.thread(recoveryThread.id).text()).toMatchInlineSnapshot(`
405
- "--- from: user (proxy-tester)
406
- recovered after proxy restart
407
- --- from: assistant (TestBot)
408
- ⬥ gateway-proxy-reply
409
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
410
- `);
411
- expect(reply.content.trim().length).toBeGreaterThan(0);
412
- }, 60_000);
361
+ }, 15_000);
362
+ // Reconnect test lives in gateway-proxy-reconnect.e2e.test.ts.
363
+ // It was here before but kills the proxy mid-suite, breaking shared
364
+ // state (bot/proxy connection) for all subsequent tests.
413
365
  test('shell command via ! prefix in thread', async () => {
414
366
  const existingMessages = await discord.thread(firstThreadId).getMessages();
415
367
  const existingIds = new Set(existingMessages.map((m) => m.id));
@@ -463,7 +415,7 @@ describeIf('gateway-proxy e2e', () => {
463
415
  `);
464
416
  expect(reply).toBeDefined();
465
417
  expect(reply.content.trim().length).toBeGreaterThan(0);
466
- }, 30_000);
418
+ }, 15_000);
467
419
  test('guild-2 message does not create thread (guild isolation)', async () => {
468
420
  await discord.channel(CHANNEL_2_ID).user(TEST_USER_ID).sendMessage({
469
421
  content: 'should not create thread in guild 2',
@@ -526,5 +478,5 @@ describeIf('gateway-proxy e2e', () => {
526
478
  finally {
527
479
  store.setState({ discordBaseUrl: previousBaseUrl });
528
480
  }
529
- }, 30_000);
481
+ }, 15_000);
530
482
  });
@@ -142,7 +142,7 @@ ${backticks}bash
142
142
  PORT=$((RANDOM % 6000 + 3000))
143
143
  tmux kill-session -t game-dev 2>/dev/null
144
144
  tmux new-session -d -s game-dev -c "$PWD"
145
- tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel -p $PORT -- bun run server.ts" Enter
145
+ tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel --kill -p $PORT -- bun run server.ts" Enter
146
146
  ${backticks}
147
147
 
148
148
  Wait a moment, then get the tunnel URL:
@@ -299,6 +299,41 @@ export function createDeterministicMatchers() {
299
299
  ],
300
300
  },
301
301
  };
302
+ // Question tool for select+queue drain test: model asks a question via dropdown,
303
+ // user answers via select menu while a message is queued.
304
+ const questionSelectQueueMatcher = {
305
+ id: 'question-select-queue-marker',
306
+ priority: 107,
307
+ when: {
308
+ lastMessageRole: 'user',
309
+ latestUserTextIncludes: 'QUESTION_SELECT_QUEUE_MARKER',
310
+ },
311
+ then: {
312
+ parts: [
313
+ { type: 'stream-start', warnings: [] },
314
+ {
315
+ type: 'tool-call',
316
+ toolCallId: 'question-select-queue-call',
317
+ toolName: 'question',
318
+ input: JSON.stringify({
319
+ questions: [{
320
+ question: 'How to proceed?',
321
+ header: 'Select action',
322
+ options: [
323
+ { label: 'Alpha', description: 'Alpha option' },
324
+ { label: 'Beta', description: 'Beta option' },
325
+ ],
326
+ }],
327
+ }),
328
+ },
329
+ {
330
+ type: 'finish',
331
+ finishReason: 'tool-calls',
332
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
333
+ },
334
+ ],
335
+ },
336
+ };
302
337
  // Model responds with text + tool call, then after tool result the
303
338
  // follow-up matcher responds with text. This creates two assistant messages:
304
339
  // first with finish="tool-calls" + completed, second with finish="stop".
@@ -613,6 +648,7 @@ export function createDeterministicMatchers() {
613
648
  pluginTimeoutSleepMatcher,
614
649
  actionButtonClickFollowupMatcher,
615
650
  questionToolMatcher,
651
+ questionSelectQueueMatcher,
616
652
  permissionTypingMatcher,
617
653
  permissionTypingFollowupMatcher,
618
654
  multiToolMatcher,