kimaki 0.17.1 → 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 (68) hide show
  1. package/dist/bin.js +5 -2
  2. package/dist/cache-drift-plugin.js +176 -0
  3. package/dist/cli-commands/bot.js +26 -0
  4. package/dist/cli-commands/session.js +4 -4
  5. package/dist/cli-runner.js +70 -18
  6. package/dist/cli-runner.test.js +23 -0
  7. package/dist/cli.js +5 -0
  8. package/dist/commands/ask-question.js +13 -3
  9. package/dist/commands/ask-question.test.js +18 -2
  10. package/dist/commands/merge-worktree.js +11 -10
  11. package/dist/commands/new-worktree.js +55 -25
  12. package/dist/commands/worktrees.js +73 -67
  13. package/dist/database.js +54 -0
  14. package/dist/db.js +13 -0
  15. package/dist/db.test.js +12 -11
  16. package/dist/discord-bot.js +59 -14
  17. package/dist/discord-utils.js +9 -8
  18. package/dist/git-worktree-core.js +296 -0
  19. package/dist/image-utils.js +1 -1
  20. package/dist/interaction-handler.js +66 -0
  21. package/dist/kimaki-opencode-plugin.js +2 -0
  22. package/dist/kimaki-workspace-adaptor.js +95 -0
  23. package/dist/opencode.js +1 -0
  24. package/dist/schema.js +16 -0
  25. package/dist/session-handler/thread-session-runtime.js +15 -15
  26. package/dist/store.js +1 -0
  27. package/dist/system-message.js +12 -0
  28. package/dist/system-message.test.js +12 -0
  29. package/dist/voice-handler.js +8 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +187 -21
  31. package/dist/worktrees.js +13 -61
  32. package/dist/worktrees.test.js +17 -0
  33. package/package.json +4 -4
  34. package/skills/playwriter/SKILL.md +1 -1
  35. package/skills/sigillo/SKILL.md +2 -2
  36. package/src/bin.ts +8 -2
  37. package/src/cache-drift-plugin.ts +239 -0
  38. package/src/cli-commands/bot.ts +34 -0
  39. package/src/cli-commands/session.ts +4 -4
  40. package/src/cli-runner.test.ts +27 -0
  41. package/src/cli-runner.ts +83 -20
  42. package/src/cli.ts +8 -0
  43. package/src/commands/ask-question.test.ts +20 -1
  44. package/src/commands/ask-question.ts +25 -7
  45. package/src/commands/merge-worktree.ts +12 -12
  46. package/src/commands/new-worktree.ts +66 -27
  47. package/src/commands/worktrees.ts +84 -86
  48. package/src/database.ts +90 -0
  49. package/src/db.test.ts +12 -11
  50. package/src/db.ts +14 -0
  51. package/src/discord-bot.ts +76 -18
  52. package/src/discord-utils.ts +9 -8
  53. package/src/git-worktree-core.ts +389 -0
  54. package/src/image-utils.ts +5 -4
  55. package/src/interaction-handler.ts +77 -0
  56. package/src/kimaki-opencode-plugin.ts +2 -0
  57. package/src/kimaki-workspace-adaptor.ts +115 -0
  58. package/src/opencode.ts +2 -1
  59. package/src/schema.sql +13 -0
  60. package/src/schema.ts +18 -0
  61. package/src/session-handler/thread-session-runtime.ts +15 -15
  62. package/src/store.ts +8 -0
  63. package/src/system-message.test.ts +12 -0
  64. package/src/system-message.ts +12 -0
  65. package/src/voice-handler.ts +11 -0
  66. package/src/worktree-lifecycle.e2e.test.ts +229 -24
  67. package/src/worktrees.test.ts +19 -0
  68. 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 };
@@ -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.
@@ -1011,7 +1054,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1011
1054
  possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1012
1055
  }),
1013
1056
  ]);
1014
- void backgroundUpgradeKimaki();
1057
+ if (store.getState().autoUpgradeEnabled) {
1058
+ void backgroundUpgradeKimaki();
1059
+ }
1015
1060
  // Start in-process Hrana server before database init. Required for the bot
1016
1061
  // process because it serves as both the DB server and the single-instance
1017
1062
  // lock (binds the fixed lock port). Without it, IPC and lock enforcement
@@ -1152,6 +1197,13 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1152
1197
  catch (error) {
1153
1198
  cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway);
1154
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
+ }
1155
1207
  process.exit(EXIT_NO_RESTART);
1156
1208
  }
1157
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
  });
@@ -3,7 +3,7 @@
3
3
  // Preserves all commits (no squash). On rebase conflicts, asks the AI model
4
4
  // in the thread to resolve them.
5
5
  import {} from 'discord.js';
6
- import { getThreadWorktree, getThreadSession, getChannelDirectory, } from '../database.js';
6
+ import { getThreadWorktreeOrWorkspace, getThreadSession, getChannelDirectory, } from '../database.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  import { notifyError } from '../sentry.js';
9
9
  import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
@@ -63,20 +63,21 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
63
63
  return;
64
64
  }
65
65
  const thread = channel;
66
- const worktreeInfo = await getThreadWorktree(thread.id);
67
- if (!worktreeInfo) {
66
+ // Check both thread_workspaces (new) and thread_worktrees (legacy)
67
+ const info = await getThreadWorktreeOrWorkspace(thread.id);
68
+ if (!info) {
68
69
  await command.editReply('This thread is not associated with a worktree');
69
70
  return;
70
71
  }
71
- if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
72
- await command.editReply(`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`);
72
+ if (info.status !== 'ready' || !info.workspace_directory) {
73
+ await command.editReply(`Worktree is not ready (status: ${info.status})${info.error_message ? `: ${info.error_message}` : ''}`);
73
74
  return;
74
75
  }
75
76
  const rawTargetBranch = command.options.getString('target-branch') || undefined;
76
77
  let targetBranch = rawTargetBranch;
77
78
  if (targetBranch) {
78
79
  const validated = await validateBranchRef({
79
- directory: worktreeInfo.project_directory,
80
+ directory: info.project_directory,
80
81
  ref: targetBranch,
81
82
  });
82
83
  if (validated instanceof Error) {
@@ -86,9 +87,9 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
86
87
  targetBranch = validated;
87
88
  }
88
89
  const result = await mergeWorktree({
89
- worktreeDir: worktreeInfo.worktree_directory,
90
- mainRepoDir: worktreeInfo.project_directory,
91
- worktreeName: worktreeInfo.worktree_name,
90
+ worktreeDir: info.workspace_directory,
91
+ mainRepoDir: info.project_directory,
92
+ worktreeName: info.workspace_name,
92
93
  targetBranch,
93
94
  onProgress: (msg) => {
94
95
  logger.log(`[merge] ${msg}`);
@@ -126,7 +127,7 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
126
127
  '9. Once the rebase is fully complete, tell me so I can run `/merge-worktree` again',
127
128
  ].join('\n'),
128
129
  thread,
129
- projectDirectory: worktreeInfo.project_directory,
130
+ projectDirectory: info.project_directory,
130
131
  command,
131
132
  appId,
132
133
  });