kimaki 0.17.1 → 0.19.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 (74) 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/send.js +34 -10
  5. package/dist/cli-commands/session.js +8 -5
  6. package/dist/cli-runner.js +70 -18
  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/merge-worktree.js +11 -10
  12. package/dist/commands/new-worktree.js +55 -25
  13. package/dist/commands/worktrees.js +73 -67
  14. package/dist/database.js +54 -0
  15. package/dist/db.js +13 -0
  16. package/dist/db.test.js +12 -11
  17. package/dist/discord-bot.js +59 -14
  18. package/dist/discord-utils.js +9 -8
  19. package/dist/git-worktree-core.js +296 -0
  20. package/dist/image-utils.js +1 -1
  21. package/dist/interaction-handler.js +66 -0
  22. package/dist/kimaki-opencode-plugin.js +2 -0
  23. package/dist/kimaki-workspace-adaptor.js +95 -0
  24. package/dist/markdown.js +59 -4
  25. package/dist/markdown.test.js +73 -2
  26. package/dist/opencode.js +1 -0
  27. package/dist/schema.js +16 -0
  28. package/dist/session-handler/thread-session-runtime.js +15 -15
  29. package/dist/store.js +1 -0
  30. package/dist/system-message.js +12 -0
  31. package/dist/system-message.test.js +12 -0
  32. package/dist/voice-handler.js +8 -0
  33. package/dist/worktree-lifecycle.e2e.test.js +187 -21
  34. package/dist/worktrees.js +13 -61
  35. package/dist/worktrees.test.js +17 -0
  36. package/package.json +3 -3
  37. package/skills/playwriter/SKILL.md +1 -1
  38. package/skills/sigillo/SKILL.md +36 -2
  39. package/src/bin.ts +8 -2
  40. package/src/cache-drift-plugin.ts +239 -0
  41. package/src/cli-commands/bot.ts +34 -0
  42. package/src/cli-commands/send.ts +37 -13
  43. package/src/cli-commands/session.ts +9 -6
  44. package/src/cli-runner.test.ts +27 -0
  45. package/src/cli-runner.ts +83 -20
  46. package/src/cli.ts +8 -0
  47. package/src/commands/ask-question.test.ts +20 -1
  48. package/src/commands/ask-question.ts +25 -7
  49. package/src/commands/merge-worktree.ts +12 -12
  50. package/src/commands/new-worktree.ts +66 -27
  51. package/src/commands/worktrees.ts +84 -86
  52. package/src/database.ts +90 -0
  53. package/src/db.test.ts +12 -11
  54. package/src/db.ts +14 -0
  55. package/src/discord-bot.ts +76 -18
  56. package/src/discord-utils.ts +9 -8
  57. package/src/git-worktree-core.ts +389 -0
  58. package/src/image-utils.ts +5 -4
  59. package/src/interaction-handler.ts +77 -0
  60. package/src/kimaki-opencode-plugin.ts +2 -0
  61. package/src/kimaki-workspace-adaptor.ts +115 -0
  62. package/src/markdown.test.ts +89 -2
  63. package/src/markdown.ts +63 -4
  64. package/src/opencode.ts +2 -1
  65. package/src/schema.sql +13 -0
  66. package/src/schema.ts +18 -0
  67. package/src/session-handler/thread-session-runtime.ts +15 -15
  68. package/src/store.ts +8 -0
  69. package/src/system-message.test.ts +12 -0
  70. package/src/system-message.ts +12 -0
  71. package/src/voice-handler.ts +11 -0
  72. package/src/worktree-lifecycle.e2e.test.ts +229 -24
  73. package/src/worktrees.test.ts +19 -0
  74. 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)')
@@ -1,4 +1,8 @@
1
1
  // Terminal send command for creating Discord threads and scheduling prompts.
2
+ // Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
3
+ // The local SQLite database (channel_directories) is NOT required for the basic
4
+ // flow: post message → create thread → remote bot picks it up. The local project
5
+ // directory mapping is only needed for --send-at, --wait, and --cwd.
2
6
  import { goke } from 'goke';
3
7
  import { z } from 'zod';
4
8
  import { note } from '@clack/prompts';
@@ -271,9 +275,18 @@ cli
271
275
  if (!threadData.parent_id) {
272
276
  throw new Error(`Thread has no parent channel: ${targetThreadId}`);
273
277
  }
278
+ // channelConfig is optional: in CI/headless environments the local DB
279
+ // has no channel_directories rows because the bot hasn't synced yet.
280
+ // The running bot on the other end resolves the directory from its own DB.
281
+ // We only require it for features that genuinely need a local directory
282
+ // (scheduled tasks and --wait).
274
283
  const channelConfig = await getChannelDirectory(threadData.parent_id);
275
- if (!channelConfig) {
276
- throw new Error('Thread parent channel is not configured with a project directory');
284
+ // Guard early: fail before sending the message if a feature that
285
+ // needs local project directory mapping is requested.
286
+ if (!channelConfig && (parsedSchedule || options.wait)) {
287
+ const flag = parsedSchedule ? '--send-at' : '--wait';
288
+ throw new Error('Thread parent channel is not configured with a project directory. ' +
289
+ `${flag} requires a local project mapping. Run the bot first to sync channel data.`);
277
290
  }
278
291
  if (parsedSchedule) {
279
292
  const payload = {
@@ -298,6 +311,7 @@ cli
298
311
  channelId: threadData.parent_id,
299
312
  threadId: targetThreadId,
300
313
  sessionId: sessionId || undefined,
314
+ // channelConfig is guaranteed: early guard threw if missing with --send-at
301
315
  projectDirectory: channelConfig.directory,
302
316
  });
303
317
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
@@ -337,6 +351,8 @@ cli
337
351
  process.stdout.write(`Session: ${existingSessionId}\n`);
338
352
  process.stdout.write(`${threadUrl}\n`);
339
353
  if (options.wait) {
354
+ // channelConfig is guaranteed here: early guard above already
355
+ // threw if channelConfig is missing when --wait is used.
340
356
  const { waitAndOutputSession } = await import('../wait-session.js');
341
357
  await waitAndOutputSession({
342
358
  threadId: targetThreadId,
@@ -352,17 +368,25 @@ cli
352
368
  }
353
369
  // Get channel info to extract directory from topic
354
370
  const channelData = (await rest.get(Routes.channel(channelId)));
371
+ // channelConfig is optional: in CI/headless environments the local DB
372
+ // has no channel_directories rows because the bot hasn't synced yet.
373
+ // The running bot on the other end resolves the directory from its own DB.
374
+ // We only require it for features that genuinely need a local directory
375
+ // (--send-at, --wait, --cwd).
355
376
  const channelConfig = await getChannelDirectory(channelData.id);
356
- if (!channelConfig && !notifyOnly) {
357
- cliLogger.log('Channel not configured');
358
- throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
359
- }
360
377
  const projectDirectory = channelConfig?.directory;
378
+ // Features that require a local project directory mapping
379
+ const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
380
+ if (!channelConfig && needsProjectDirectory) {
381
+ throw new Error(`Channel #${channelData.name} is not configured with a project directory. ` +
382
+ `${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
383
+ 'Run the bot first to sync channel data.');
384
+ }
361
385
  // Validate --cwd is inside the project or an existing git worktree.
362
386
  let resolvedCwd;
363
387
  if (options.cwd) {
364
- // projectDirectory is guaranteed here: --cwd is incompatible with --notify-only,
365
- // and non-notify sends already require channelConfig above.
388
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
389
+ // already threw if channelConfig is missing when --cwd is used.
366
390
  const cwdResult = await resolveSessionWorkingDirectory({
367
391
  projectDirectory: projectDirectory,
368
392
  candidatePath: options.cwd,
@@ -510,8 +534,8 @@ cli
510
534
  process.stdout.write(`Session: ${newSessionId}\n`);
511
535
  process.stdout.write(`${threadUrl}\n`);
512
536
  if (options.wait) {
513
- // projectDirectory is guaranteed here: --wait is incompatible with --notify-only,
514
- // and non-notify sends already require channelConfig above.
537
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
538
+ // already threw if channelConfig is missing when --wait is used.
515
539
  const { waitAndOutputSession } = await import('../wait-session.js');
516
540
  await waitAndOutputSession({
517
541
  threadId: threadData.id,
@@ -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);
@@ -126,6 +126,7 @@ cli
126
126
  cli
127
127
  .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
128
128
  .option('--project <path>', 'Project directory (defaults to cwd)')
129
+ .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
129
130
  .action(async (sessionId, options) => {
130
131
  try {
131
132
  const projectDirectory = path.resolve(options.project || '.');
@@ -137,8 +138,9 @@ cli
137
138
  process.exit(EXIT_NO_RESTART);
138
139
  }
139
140
  // Try current project first (fast path)
141
+ const compactTools = !options.verbose;
140
142
  const markdown = new ShareMarkdown(getClient());
141
- const result = await markdown.generate({ sessionID: sessionId });
143
+ const result = await markdown.generate({ sessionID: sessionId, compactTools });
142
144
  if (!(result instanceof Error)) {
143
145
  process.stdout.write(result);
144
146
  process.exit(0);
@@ -171,6 +173,7 @@ cli
171
173
  const otherMarkdown = new ShareMarkdown(otherClient());
172
174
  const otherResult = await otherMarkdown.generate({
173
175
  sessionID: sessionId,
176
+ compactTools,
174
177
  });
175
178
  if (!(otherResult instanceof Error)) {
176
179
  process.stdout.write(otherResult);
@@ -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
  });