kimaki 0.17.0 → 0.18.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 (76) hide show
  1. package/dist/bin.js +5 -2
  2. package/dist/cache-drift-plugin.js +176 -0
  3. package/dist/channel-management.js +6 -8
  4. package/dist/cli-commands/bot.js +26 -0
  5. package/dist/cli-commands/session.js +4 -4
  6. package/dist/cli-runner.js +87 -22
  7. package/dist/cli-runner.test.js +23 -0
  8. package/dist/cli.js +5 -0
  9. package/dist/commands/ask-question.js +13 -3
  10. package/dist/commands/ask-question.test.js +18 -2
  11. package/dist/commands/last-sessions.js +3 -2
  12. package/dist/commands/merge-worktree.js +11 -10
  13. package/dist/commands/new-worktree.js +55 -25
  14. package/dist/commands/worktrees.js +92 -70
  15. package/dist/database.js +54 -0
  16. package/dist/db.js +13 -0
  17. package/dist/db.test.js +12 -11
  18. package/dist/discord-bot.js +59 -14
  19. package/dist/discord-utils.js +9 -8
  20. package/dist/format-tables.js +138 -1
  21. package/dist/format-tables.test.js +224 -1
  22. package/dist/git-worktree-core.js +296 -0
  23. package/dist/image-utils.js +1 -1
  24. package/dist/interaction-handler.js +92 -7
  25. package/dist/kimaki-opencode-plugin.js +2 -0
  26. package/dist/kimaki-workspace-adaptor.js +95 -0
  27. package/dist/opencode.js +1 -0
  28. package/dist/schema.js +16 -0
  29. package/dist/session-handler/thread-session-runtime.js +15 -15
  30. package/dist/store.js +1 -0
  31. package/dist/system-message.js +12 -0
  32. package/dist/system-message.test.js +12 -0
  33. package/dist/voice-handler.js +14 -0
  34. package/dist/worktree-lifecycle.e2e.test.js +187 -21
  35. package/dist/worktrees.js +13 -61
  36. package/dist/worktrees.test.js +17 -0
  37. package/package.json +3 -3
  38. package/skills/playwriter/SKILL.md +1 -1
  39. package/skills/sigillo/SKILL.md +2 -2
  40. package/src/bin.ts +8 -2
  41. package/src/cache-drift-plugin.ts +239 -0
  42. package/src/channel-management.ts +6 -8
  43. package/src/cli-commands/bot.ts +34 -0
  44. package/src/cli-commands/session.ts +4 -4
  45. package/src/cli-runner.test.ts +27 -0
  46. package/src/cli-runner.ts +102 -26
  47. package/src/cli.ts +8 -0
  48. package/src/commands/ask-question.test.ts +20 -1
  49. package/src/commands/ask-question.ts +25 -7
  50. package/src/commands/last-sessions.ts +4 -2
  51. package/src/commands/merge-worktree.ts +12 -12
  52. package/src/commands/new-worktree.ts +66 -27
  53. package/src/commands/worktrees.ts +104 -89
  54. package/src/database.ts +90 -0
  55. package/src/db.test.ts +12 -11
  56. package/src/db.ts +14 -0
  57. package/src/discord-bot.ts +76 -18
  58. package/src/discord-utils.ts +9 -8
  59. package/src/format-tables.test.ts +246 -0
  60. package/src/format-tables.ts +176 -1
  61. package/src/git-worktree-core.ts +389 -0
  62. package/src/image-utils.ts +5 -4
  63. package/src/interaction-handler.ts +102 -9
  64. package/src/kimaki-opencode-plugin.ts +2 -0
  65. package/src/kimaki-workspace-adaptor.ts +115 -0
  66. package/src/opencode.ts +2 -1
  67. package/src/schema.sql +13 -0
  68. package/src/schema.ts +18 -0
  69. package/src/session-handler/thread-session-runtime.ts +15 -15
  70. package/src/store.ts +8 -0
  71. package/src/system-message.test.ts +12 -0
  72. package/src/system-message.ts +12 -0
  73. package/src/voice-handler.ts +21 -0
  74. package/src/worktree-lifecycle.e2e.test.ts +229 -24
  75. package/src/worktrees.test.ts +19 -0
  76. package/src/worktrees.ts +13 -75
package/dist/bin.js CHANGED
@@ -69,8 +69,11 @@ else {
69
69
  return;
70
70
  }
71
71
  const reason = signal ? `signal ${signal}` : `code ${code}`;
72
- console.error(`[kimaki] Process exited with ${reason}, restarting in ${RESTART_DELAY_MS / 1000}s...`);
73
- setTimeout(start, RESTART_DELAY_MS);
72
+ // Progressive backoff: 2s, 4s, 8s, 16s, capped at 30s.
73
+ // Prevents hammering DNS/gateway during sustained network outages.
74
+ const delay = Math.min(RESTART_DELAY_MS * 2 ** (restartTimestamps.length - 1), 30_000);
75
+ console.error(`[kimaki] Process exited with ${reason}, restarting in ${(delay / 1000).toFixed(0)}s...`);
76
+ setTimeout(start, delay);
74
77
  });
75
78
  }
76
79
  // Forward signals to child so graceful shutdown and heap snapshots work.
@@ -0,0 +1,176 @@
1
+ // OpenCode plugin that detects system prompt drift across turns.
2
+ // When the system prompt changes between user messages, it logs a warning
3
+ // with addition/deletion line counts so you know context cache was discarded.
4
+ // This helps spot plugins that mutate the system prompt unexpectedly.
5
+ import { diffLines } from 'diff';
6
+ import * as errore from 'errore';
7
+ import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
8
+ import { initSentry, notifyError } from './sentry.js';
9
+ const logger = createPluginLogger('OPENCODE');
10
+ function normalizeSystemPrompt({ system }) {
11
+ return system.join('\n');
12
+ }
13
+ function buildTurnContext({ input, directory, }) {
14
+ const model = input.model
15
+ ? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
16
+ : undefined;
17
+ return {
18
+ agent: input.agent,
19
+ model,
20
+ directory,
21
+ };
22
+ }
23
+ function shouldSuppressDriftWarning({ previousContext, currentContext, }) {
24
+ if (!previousContext || !currentContext)
25
+ return false;
26
+ return (previousContext.agent !== currentContext.agent
27
+ || previousContext.model !== currentContext.model
28
+ || previousContext.directory !== currentContext.directory);
29
+ }
30
+ function countDiffLines({ beforeText, afterText, }) {
31
+ const changes = diffLines(beforeText, afterText);
32
+ let additions = 0;
33
+ let deletions = 0;
34
+ for (const change of changes) {
35
+ if (change.added)
36
+ additions += change.count ?? 0;
37
+ if (change.removed)
38
+ deletions += change.count ?? 0;
39
+ }
40
+ return { additions, deletions };
41
+ }
42
+ function getOrCreateSessionState({ sessions, sessionId, }) {
43
+ const existing = sessions.get(sessionId);
44
+ if (existing)
45
+ return existing;
46
+ const state = {
47
+ userTurnCount: 0,
48
+ previousTurnPrompt: undefined,
49
+ latestTurnPrompt: undefined,
50
+ latestTurnPromptTurn: 0,
51
+ comparedTurn: 0,
52
+ previousTurnContext: undefined,
53
+ currentTurnContext: undefined,
54
+ pendingCompareTimeout: undefined,
55
+ };
56
+ sessions.set(sessionId, state);
57
+ return state;
58
+ }
59
+ function handleSystemTransform({ input, output, sessions, }) {
60
+ const sessionId = input.sessionID;
61
+ if (!sessionId)
62
+ return;
63
+ const currentPrompt = normalizeSystemPrompt({ system: output.system });
64
+ const state = getOrCreateSessionState({ sessions, sessionId });
65
+ const currentTurn = state.userTurnCount;
66
+ state.latestTurnPrompt = currentPrompt;
67
+ state.latestTurnPromptTurn = currentTurn;
68
+ if (currentTurn <= 1)
69
+ return;
70
+ if (state.comparedTurn === currentTurn)
71
+ return;
72
+ const previousPrompt = state.previousTurnPrompt;
73
+ state.comparedTurn = currentTurn;
74
+ if (!previousPrompt || previousPrompt === currentPrompt)
75
+ return;
76
+ if (shouldSuppressDriftWarning({
77
+ previousContext: state.previousTurnContext,
78
+ currentContext: state.currentTurnContext,
79
+ })) {
80
+ return;
81
+ }
82
+ const { additions, deletions } = countDiffLines({
83
+ beforeText: previousPrompt,
84
+ afterText: currentPrompt,
85
+ });
86
+ logger.warn(`[cache-drift] context cache discarded for session ${sessionId}: `
87
+ + `system prompt changed since previous message (+${additions} / -${deletions} lines)`);
88
+ }
89
+ function getDeletedSessionId({ event }) {
90
+ if (event.type !== 'session.deleted')
91
+ return undefined;
92
+ const sessionInfo = event.properties?.info;
93
+ if (!sessionInfo || typeof sessionInfo !== 'object')
94
+ return undefined;
95
+ const id = sessionInfo.id;
96
+ return typeof id === 'string' ? id : undefined;
97
+ }
98
+ const cacheDriftPlugin = async ({ directory }) => {
99
+ initSentry();
100
+ const dataDir = process.env.KIMAKI_DATA_DIR;
101
+ if (dataDir) {
102
+ setPluginLogFilePath(dataDir);
103
+ }
104
+ const sessions = new Map();
105
+ return {
106
+ 'chat.message': async (input) => {
107
+ const sessionId = input.sessionID;
108
+ if (!sessionId)
109
+ return;
110
+ const state = getOrCreateSessionState({ sessions, sessionId });
111
+ if (state.userTurnCount > 0
112
+ && state.latestTurnPromptTurn === state.userTurnCount) {
113
+ state.previousTurnPrompt = state.latestTurnPrompt;
114
+ state.previousTurnContext = state.currentTurnContext;
115
+ }
116
+ state.currentTurnContext = buildTurnContext({ input, directory });
117
+ state.userTurnCount += 1;
118
+ },
119
+ 'experimental.chat.system.transform': async (input, output) => {
120
+ const result = errore.try({
121
+ try: () => {
122
+ const sessionId = input.sessionID;
123
+ if (!sessionId)
124
+ return;
125
+ const state = getOrCreateSessionState({ sessions, sessionId });
126
+ if (state.pendingCompareTimeout) {
127
+ clearTimeout(state.pendingCompareTimeout);
128
+ }
129
+ // Delay one tick so other system-transform hooks can finish mutating
130
+ // output.system before we snapshot it for drift detection.
131
+ state.pendingCompareTimeout = setTimeout(() => {
132
+ state.pendingCompareTimeout = undefined;
133
+ try {
134
+ handleSystemTransform({ input, output, sessions });
135
+ }
136
+ catch (err) {
137
+ logger.warn(`[cache-drift] ${formatPluginErrorWithStack(err)}`);
138
+ void notifyError(err, 'cache drift plugin transform hook failed');
139
+ }
140
+ }, 0);
141
+ },
142
+ catch: (error) => {
143
+ return new Error('cache drift transform hook failed', { cause: error });
144
+ },
145
+ });
146
+ if (result instanceof Error) {
147
+ logger.warn(`[cache-drift] ${formatPluginErrorWithStack(result)}`);
148
+ void notifyError(result, 'cache drift plugin transform hook failed');
149
+ }
150
+ },
151
+ event: async ({ event }) => {
152
+ const result = errore.try({
153
+ try: () => {
154
+ if (event.type !== 'session.deleted')
155
+ return;
156
+ const deletedSessionId = getDeletedSessionId({ event });
157
+ if (!deletedSessionId)
158
+ return;
159
+ const state = sessions.get(deletedSessionId);
160
+ if (state?.pendingCompareTimeout) {
161
+ clearTimeout(state.pendingCompareTimeout);
162
+ }
163
+ sessions.delete(deletedSessionId);
164
+ },
165
+ catch: (error) => {
166
+ return new Error('cache drift event hook failed', { cause: error });
167
+ },
168
+ });
169
+ if (result instanceof Error) {
170
+ logger.warn(`[cache-drift] ${formatPluginErrorWithStack(result)}`);
171
+ void notifyError(result, 'cache drift plugin event hook failed');
172
+ }
173
+ },
174
+ };
175
+ };
176
+ export { cacheDriftPlugin };
@@ -153,7 +153,11 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
153
153
  logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
154
154
  return null;
155
155
  }
156
- // 2. Fallback: detect existing channel by name+category
156
+ // 2. Fallback: detect existing channel by name+category.
157
+ // If a "kimaki" channel already exists in the guild but is NOT in our local
158
+ // DB, it was likely created by another kimaki instance (different machine).
159
+ // Do NOT adopt it — just skip channel creation entirely to avoid both
160
+ // instances fighting over the same channel.
157
161
  const kimakiCategory = await ensureKimakiCategory(guild, botName);
158
162
  const existingByName = guild.channels.cache.find((ch) => {
159
163
  if (ch.type !== ChannelType.GuildText) {
@@ -165,13 +169,7 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
165
169
  return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
166
170
  });
167
171
  if (existingByName) {
168
- logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, restoring DB mapping`);
169
- await setChannelDirectory({
170
- channelId: existingByName.id,
171
- directory: projectDirectory,
172
- channelType: 'text',
173
- skipIfExists: true,
174
- });
172
+ logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`);
175
173
  return null;
176
174
  }
177
175
  // Git init — gracefully skip if git is not installed
@@ -185,6 +185,32 @@ cli
185
185
  process.exit(EXIT_NO_RESTART);
186
186
  }
187
187
  });
188
+ cli
189
+ .command('bot token', 'Print the bot token for use in CI and automation (KIMAKI_BOT_TOKEN)')
190
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
191
+ .action(async (options) => {
192
+ try {
193
+ if (options.dataDir) {
194
+ setDataDir(options.dataDir);
195
+ }
196
+ initLogFile(getDataDir());
197
+ await initDatabase();
198
+ const botRow = await getBotTokenWithMode();
199
+ if (!botRow) {
200
+ cliLogger.error('No bot configured. Run `kimaki` first.');
201
+ process.exit(EXIT_NO_RESTART);
202
+ }
203
+ // Print the token to stdout so it can be captured by scripts.
204
+ // Use process.stdout.write to avoid extra newline from console.log
205
+ // when piping to other commands.
206
+ process.stdout.write(botRow.token + '\n');
207
+ process.exit(0);
208
+ }
209
+ catch (error) {
210
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
211
+ process.exit(EXIT_NO_RESTART);
212
+ }
213
+ });
188
214
  cli
189
215
  .command('bot status clear', 'Clear the bot presence/status')
190
216
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { spawn, execSync } from 'node:child_process';
12
12
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
13
13
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
14
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getThreadWorktree } from '../database.js';
14
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getThreadWorktreeOrWorkspace } from '../database.js';
15
15
  import { ShareMarkdown } from '../markdown.js';
16
16
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
17
17
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
@@ -29,9 +29,9 @@ const cli = goke();
29
29
  async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
30
30
  const threadId = await getThreadIdBySessionId(sessionId);
31
31
  if (threadId) {
32
- const worktree = await getThreadWorktree(threadId);
33
- if (worktree?.status === 'ready' && worktree.worktree_directory) {
34
- return worktree.worktree_directory;
32
+ const workspace = await getThreadWorktreeOrWorkspace(threadId);
33
+ if (workspace?.status === 'ready' && workspace.workspace_directory) {
34
+ return workspace.workspace_directory;
35
35
  }
36
36
  const { token: botToken } = await resolveBotCredentials({});
37
37
  const rest = createDiscordRest(botToken);
@@ -37,6 +37,27 @@ export const KIMAKI_GATEWAY_PROXY_URL = process.env.KIMAKI_GATEWAY_PROXY_URL ||
37
37
  export const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
38
38
  gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
39
39
  });
40
+ export function getOpenUrlCommand(url, platform = process.platform) {
41
+ if (platform === 'darwin') {
42
+ return { command: 'open', args: [url] };
43
+ }
44
+ if (platform === 'win32') {
45
+ return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
46
+ }
47
+ return { command: 'xdg-open', args: [url] };
48
+ }
49
+ function openUrlInDefaultBrowser(url) {
50
+ const { command, args } = getOpenUrlCommand(url);
51
+ const child = spawn(command, args, {
52
+ detached: true,
53
+ stdio: 'ignore',
54
+ windowsHide: true,
55
+ });
56
+ child.on('error', (error) => {
57
+ cliLogger.warn('Failed to open install URL:', error instanceof Error ? error.message : String(error));
58
+ });
59
+ child.unref();
60
+ }
40
61
  // Strip bracketed paste escape sequences from terminal input.
41
62
  // iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
42
63
  // which can cause validation to fail on macOS. See: https://github.com/remorses/kimaki/issues/18
@@ -79,11 +100,23 @@ export function appIdFromToken(token) {
79
100
  // but don't run the interactive wizard.
80
101
  // In gateway mode, also sets store.discordBaseUrl so REST calls
81
102
  // are routed through the gateway-proxy REST endpoint.
103
+ //
104
+ // Priority: KIMAKI_BOT_TOKEN env var takes precedence over saved DB
105
+ // credentials. This lets CI and cross-instance commands override the
106
+ // local bot identity. If the env token looks like a gateway credential
107
+ // (clientId:clientSecret), the gateway proxy REST URL is set automatically.
82
108
  export async function resolveBotCredentials({ appIdOverride } = {}) {
83
- // DB first: getBotTokenWithMode() sets store.discordBaseUrl which is
84
- // required in gateway mode so REST calls route through the proxy.
85
- // Without this, inherited KIMAKI_BOT_TOKEN (a gateway credential like
86
- // clientId:clientSecret) would be sent directly to discord.com → 401.
109
+ const envToken = process.env.KIMAKI_BOT_TOKEN;
110
+ if (envToken) {
111
+ const isGatewayToken = envToken.includes(':');
112
+ if (isGatewayToken) {
113
+ // Gateway tokens need REST calls routed through the proxy, not discord.com
114
+ store.setState({ discordBaseUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL });
115
+ }
116
+ const appId = appIdOverride || appIdFromToken(envToken);
117
+ return { token: envToken, appId };
118
+ }
119
+ // Fall back to saved credentials in the database
87
120
  const botRow = await getBotTokenWithMode().catch((e) => {
88
121
  cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
89
122
  return null;
@@ -91,12 +124,6 @@ export async function resolveBotCredentials({ appIdOverride } = {}) {
91
124
  if (botRow) {
92
125
  return { token: botRow.token, appId: appIdOverride || botRow.appId };
93
126
  }
94
- // Fall back to env var for CI/headless deployments with no database
95
- const envToken = process.env.KIMAKI_BOT_TOKEN;
96
- if (envToken) {
97
- const appId = appIdOverride || appIdFromToken(envToken);
98
- return { token: envToken, appId };
99
- }
100
127
  cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
101
128
  process.exit(EXIT_NO_RESTART);
102
129
  }
@@ -281,6 +308,28 @@ function readErrorField(error, key) {
281
308
  }
282
309
  return undefined;
283
310
  }
311
+ /** Transient network errors that may resolve on retry (DNS down, gateway unreachable). */
312
+ const TRANSIENT_ERROR_CODES = new Set([
313
+ 'ENOTFOUND',
314
+ 'ECONNREFUSED',
315
+ 'ETIMEDOUT',
316
+ 'ECONNRESET',
317
+ 'EAI_AGAIN',
318
+ 'EPIPE',
319
+ 'EHOSTUNREACH',
320
+ 'ENETUNREACH',
321
+ ]);
322
+ export function isTransientNetworkError(error) {
323
+ if (!(error instanceof Error))
324
+ return false;
325
+ const code = error.code;
326
+ if (code && TRANSIENT_ERROR_CODES.has(code))
327
+ return true;
328
+ // discord.js wraps errors in cause chains
329
+ if (error.cause instanceof Error)
330
+ return isTransientNetworkError(error.cause);
331
+ return false;
332
+ }
284
333
  export function isDiscordMemberLookupUnavailable(error) {
285
334
  const status = readErrorField(error, 'status');
286
335
  if (status === 403) {
@@ -827,13 +876,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
827
876
  if (isInteractive) {
828
877
  note(`Open this URL to install the Kimaki bot in your Discord server:\n\n${oauthUrl}\n\nDo not share this URL with anyone — it contains your credentials.\n\nIf you don't have a server, create one first (+ button in the Discord sidebar).`, 'Install Bot');
829
878
  // Open URL in default browser
830
- const { exec } = await import('node:child_process');
831
- const openCmd = process.platform === 'darwin'
832
- ? 'open'
833
- : process.platform === 'win32'
834
- ? 'start'
835
- : 'xdg-open';
836
- exec(`${openCmd} "${oauthUrl}"`);
879
+ openUrlInDefaultBrowser(oauthUrl);
837
880
  }
838
881
  else {
839
882
  // Non-TTY: emit structured JSON so the host process can show the URL to the user.
@@ -848,6 +891,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
848
891
  pollUrl.searchParams.set('secret', clientSecret);
849
892
  let guildId;
850
893
  let installerDiscordUserId;
894
+ let onboardingError;
851
895
  for (let attempt = 0; attempt < 100; attempt++) {
852
896
  await new Promise((resolve) => {
853
897
  setTimeout(resolve, 3000);
@@ -860,7 +904,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
860
904
  else if (attempt === 45) {
861
905
  s?.message(`Still waiting... If you don't see any servers, create one first (+ button in Discord sidebar), then reopen the URL above`);
862
906
  }
863
- else if (attempt === 150) {
907
+ else if (attempt === 75) {
864
908
  s?.message(`Still waiting... Reopen the install URL if you closed it:\n${oauthUrl}`);
865
909
  }
866
910
  }
@@ -874,19 +918,31 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
874
918
  break;
875
919
  }
876
920
  }
921
+ else if (resp.status === 404) {
922
+ // Check if the server returned a specific onboarding error
923
+ // (e.g. guild_id missing from Discord callback)
924
+ const data = (await resp.json().catch(() => null));
925
+ if (data?.onboarding_error && data.error) {
926
+ onboardingError = data.error;
927
+ break;
928
+ }
929
+ }
877
930
  }
878
931
  catch {
879
932
  // Network error, retry
880
933
  }
881
934
  }
882
935
  if (!guildId) {
936
+ const errorMsg = onboardingError
937
+ ? `Authorization failed: ${onboardingError}`
938
+ : 'Bot authorization timed out after 5 minutes. Please try again.';
883
939
  if (isInteractive) {
884
- s?.stop('Authorization timed out');
940
+ s?.stop(onboardingError ? 'Authorization failed' : 'Authorization timed out');
885
941
  }
886
942
  else {
887
- emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
943
+ emitJsonEvent({ type: 'error', message: errorMsg });
888
944
  }
889
- cliLogger.error('Bot authorization timed out after 5 minutes. Please try again.');
945
+ cliLogger.error(errorMsg);
890
946
  process.exit(EXIT_NO_RESTART);
891
947
  }
892
948
  if (isInteractive) {
@@ -998,7 +1054,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
998
1054
  possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
999
1055
  }),
1000
1056
  ]);
1001
- void backgroundUpgradeKimaki();
1057
+ if (store.getState().autoUpgradeEnabled) {
1058
+ void backgroundUpgradeKimaki();
1059
+ }
1002
1060
  // Start in-process Hrana server before database init. Required for the bot
1003
1061
  // process because it serves as both the DB server and the single-instance
1004
1062
  // lock (binds the fixed lock port). Without it, IPC and lock enforcement
@@ -1139,6 +1197,13 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1139
1197
  catch (error) {
1140
1198
  cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway);
1141
1199
  cliLogger.error('Error: ' + (error instanceof Error ? error.stack : String(error)));
1200
+ // Transient network errors (DNS down, gateway unreachable) should allow
1201
+ // the bin.ts wrapper to restart us after a delay. Only truly fatal errors
1202
+ // (bad token, invalid intent, etc.) should use EXIT_NO_RESTART.
1203
+ if (isTransientNetworkError(error)) {
1204
+ cliLogger.error('Transient network error, exiting for wrapper restart...');
1205
+ process.exit(1);
1206
+ }
1142
1207
  process.exit(EXIT_NO_RESTART);
1143
1208
  }
1144
1209
  await setBotToken(appId, token);
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { getOpenUrlCommand } from './cli-runner.js';
3
+ describe('getOpenUrlCommand', () => {
4
+ const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def';
5
+ test('uses a shell-free opener on Windows', () => {
6
+ expect(getOpenUrlCommand(installUrl, 'win32')).toEqual({
7
+ command: 'rundll32.exe',
8
+ args: ['url.dll,FileProtocolHandler', installUrl],
9
+ });
10
+ });
11
+ test('uses open on macOS', () => {
12
+ expect(getOpenUrlCommand(installUrl, 'darwin')).toEqual({
13
+ command: 'open',
14
+ args: [installUrl],
15
+ });
16
+ });
17
+ test('uses xdg-open on Linux', () => {
18
+ expect(getOpenUrlCommand(installUrl, 'linux')).toEqual({
19
+ command: 'xdg-open',
20
+ args: [installUrl],
21
+ });
22
+ });
23
+ });
package/dist/cli.js CHANGED
@@ -43,6 +43,7 @@ cli
43
43
  .option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
44
44
  .option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
45
45
  .option('--no-sentry', 'Disable Sentry error reporting')
46
+ .option('--no-auto-upgrade', 'Disable background auto-upgrade on startup')
46
47
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
47
48
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
48
49
  .option('--allow-mention <type>', z
@@ -156,6 +157,7 @@ cli
156
157
  ...(options.noCritique && { critiqueEnabled: false }),
157
158
  ...(options.allowAllUsers && { allowAllUsers: true }),
158
159
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
160
+ ...(options.noAutoUpgrade && { autoUpgradeEnabled: false }),
159
161
  ...(options.disableSync && { syncEnabled: false }),
160
162
  ...(enabledSkills.length > 0 && { enabledSkills }),
161
163
  ...(disabledSkills.length > 0 && { disabledSkills }),
@@ -182,6 +184,9 @@ cli
182
184
  if (options.noCritique) {
183
185
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
184
186
  }
187
+ if (options.noAutoUpgrade) {
188
+ cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
189
+ }
185
190
  if (options.disableSync) {
186
191
  cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
187
192
  }
@@ -11,6 +11,14 @@ const logger = createLogger(LogPrefix.ASK_QUESTION);
11
11
  // TTL prevents unbounded growth if user never answers a question.
12
12
  const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
13
13
  export const pendingQuestionContexts = new Map();
14
+ export function areAllQuestionsAnswered({ totalQuestions, answers, }) {
15
+ for (let i = 0; i < totalQuestions; i++) {
16
+ if (!answers[i]) {
17
+ return false;
18
+ }
19
+ }
20
+ return true;
21
+ }
14
22
  export function findPendingQuestionContextForRequest({ threadId, requestId, }) {
15
23
  for (const [contextHash, context] of pendingQuestionContexts) {
16
24
  if (context.thread.id !== threadId) {
@@ -64,7 +72,6 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
64
72
  questions: input.questions,
65
73
  answers: {},
66
74
  totalQuestions: input.questions.length,
67
- answeredCount: 0,
68
75
  contextHash,
69
76
  };
70
77
  pendingQuestionContexts.set(contextHash, context);
@@ -163,6 +170,10 @@ export async function handleAskQuestionSelectMenu(interaction) {
163
170
  logger.error(`Question index ${questionIndex} not found in context`);
164
171
  return;
165
172
  }
173
+ if (context.answers[questionIndex]) {
174
+ logger.log(`Ignored duplicate answer for question ${context.requestId} index ${questionIndex}`);
175
+ return;
176
+ }
166
177
  // Check if "other" was selected
167
178
  if (selectedValues.includes('other')) {
168
179
  // User wants to provide custom answer
@@ -176,7 +187,6 @@ export async function handleAskQuestionSelectMenu(interaction) {
176
187
  return question.options[optIdx]?.label || `Option ${optIdx + 1}`;
177
188
  });
178
189
  }
179
- context.answeredCount++;
180
190
  // Update this question's message: show answer and remove dropdown
181
191
  const answeredText = context.answers[questionIndex].join(', ');
182
192
  await interaction.editReply({
@@ -186,7 +196,7 @@ export async function handleAskQuestionSelectMenu(interaction) {
186
196
  const username = interaction.user.globalName || interaction.user.username;
187
197
  await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
188
198
  // Check if all questions are answered
189
- if (context.answeredCount >= context.totalQuestions) {
199
+ if (areAllQuestionsAnswered(context)) {
190
200
  // All questions answered - send result back to session
191
201
  await submitQuestionAnswers(context);
192
202
  deletePendingQuestionContextsForRequest({
@@ -1,6 +1,6 @@
1
1
  // Tests AskUserQuestion request deduplication and cleanup helpers.
2
2
  import { afterEach, describe, expect, test, vi } from 'vitest';
3
- import { deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
3
+ import { areAllQuestionsAnswered, deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
4
4
  function createFakeThread() {
5
5
  const send = vi.fn(async () => {
6
6
  return { id: 'msg-1' };
@@ -69,7 +69,6 @@ describe('ask-question', () => {
69
69
  }],
70
70
  answers: {},
71
71
  totalQuestions: 1,
72
- answeredCount: 0,
73
72
  contextHash: 'ctx-1',
74
73
  };
75
74
  pendingQuestionContexts.set('ctx-1', baseContext);
@@ -89,4 +88,21 @@ describe('ask-question', () => {
89
88
  expect(removed).toBe(2);
90
89
  expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3']);
91
90
  });
91
+ test('requires every question to have an answer', () => {
92
+ expect(areAllQuestionsAnswered({
93
+ totalQuestions: 3,
94
+ answers: {
95
+ 0: ['Alpha'],
96
+ 2: ['Gamma'],
97
+ },
98
+ })).toBe(false);
99
+ expect(areAllQuestionsAnswered({
100
+ totalQuestions: 3,
101
+ answers: {
102
+ 0: ['Alpha'],
103
+ 1: ['Beta'],
104
+ 2: ['Gamma'],
105
+ },
106
+ })).toBe(true);
107
+ });
92
108
  });
@@ -5,7 +5,7 @@ import { ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'disco
5
5
  import path from 'node:path';
6
6
  import { getDb } from '../db.js';
7
7
  import { getChannelDirectory } from '../database.js';
8
- import { splitTablesFromMarkdown } from '../format-tables.js';
8
+ import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
9
9
  import { formatTimeAgo } from './worktrees.js';
10
10
  const MAX_ROWS = 20;
11
11
  async function fetchRecentSessions({ client, }) {
@@ -104,7 +104,7 @@ export async function handleLastSessionsCommand({ command, }) {
104
104
  }
105
105
  const tableMarkdown = buildSessionTable({ rows });
106
106
  const segments = splitTablesFromMarkdown(tableMarkdown);
107
- const components = segments.flatMap((segment) => {
107
+ const allComponents = segments.flatMap((segment) => {
108
108
  if (segment.type === 'components') {
109
109
  return segment.components;
110
110
  }
@@ -114,6 +114,7 @@ export async function handleLastSessionsCommand({ command, }) {
114
114
  };
115
115
  return [textDisplay];
116
116
  });
117
+ const { components } = truncateComponents(allComponents);
117
118
  await command.editReply({
118
119
  components,
119
120
  flags: MessageFlags.IsComponentsV2,