kimaki 0.29.0 → 0.30.1

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 (143) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bin.js +7 -3
  7. package/dist/bot-lifecycle.test.js +206 -0
  8. package/dist/cache-drift-plugin.js +25 -18
  9. package/dist/cache-rewrite.js +39 -0
  10. package/dist/cache-rewrite.test.js +41 -0
  11. package/dist/cli-commands/send.js +3 -9
  12. package/dist/cli-commands/session.js +37 -6
  13. package/dist/cli-parsing.test.js +3 -3
  14. package/dist/cli-runner.js +11 -1
  15. package/dist/cli-send-thread.e2e.test.js +2 -2
  16. package/dist/cli.js +4 -4
  17. package/dist/commands/abort.js +16 -4
  18. package/dist/commands/ask-question.js +72 -23
  19. package/dist/commands/queue.js +7 -7
  20. package/dist/database.js +73 -6
  21. package/dist/db.test.js +104 -1
  22. package/dist/discord-bot.js +215 -147
  23. package/dist/discord-utils.js +15 -1
  24. package/dist/external-directory-permissions.e2e.test.js +5 -5
  25. package/dist/external-directory-project-deny.e2e.test.js +2 -2
  26. package/dist/forum-sync/watchers.js +15 -0
  27. package/dist/gateway-proxy.e2e.test.js +9 -9
  28. package/dist/hrana-server.js +68 -14
  29. package/dist/markdown.js +164 -102
  30. package/dist/markdown.test.js +88 -20
  31. package/dist/message-formatting.js +30 -2
  32. package/dist/message-formatting.test.js +25 -5
  33. package/dist/opencode.js +27 -12
  34. package/dist/question-abort-clears-dropdown.e2e.test.js +2 -2
  35. package/dist/question-abort-resume.e2e.test.js +100 -0
  36. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +2 -2
  37. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  38. package/dist/queue-advanced-abort.e2e.test.js +64 -8
  39. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  40. package/dist/queue-advanced-e2e-setup.js +63 -0
  41. package/dist/queue-advanced-footer.e2e.test.js +19 -19
  42. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  43. package/dist/queue-advanced-permissions-typing.e2e.test.js +3 -3
  44. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  45. package/dist/queue-delete-message.e2e.test.js +4 -4
  46. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  47. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  48. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  49. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  50. package/dist/session-handler/event-stream-state.js +187 -1
  51. package/dist/session-handler/event-stream-state.test.js +905 -1
  52. package/dist/session-handler/thread-session-runtime.js +222 -40
  53. package/dist/sleep.e2e.test.js +9 -9
  54. package/dist/store.js +1 -1
  55. package/dist/system-message.js +9 -10
  56. package/dist/system-message.test.js +19 -10
  57. package/dist/task-runner.js +10 -3
  58. package/dist/task-schedule.js +2 -2
  59. package/dist/task-schedule.test.js +1 -1
  60. package/dist/test-utils.js +5 -3
  61. package/dist/thread-message-queue.e2e.test.js +31 -32
  62. package/dist/undo-redo.e2e.test.js +4 -4
  63. package/dist/voice-message.e2e.test.js +64 -26
  64. package/dist/voice-routing-input.e2e.test.js +4 -4
  65. package/dist/voice-routing-question.e2e.test.js +4 -4
  66. package/dist/voice.js +107 -21
  67. package/dist/voice.test.js +52 -1
  68. package/dist/wait-session.js +24 -2
  69. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  70. package/package.json +6 -6
  71. package/skills/egaki/SKILL.md +14 -0
  72. package/skills/holocron/SKILL.md +97 -42
  73. package/skills/opensrc/SKILL.md +20 -3
  74. package/skills/spiceflow/SKILL.md +53 -172
  75. package/src/agent-model.e2e.test.ts +33 -33
  76. package/src/anthropic-account-identity.ts +1 -1
  77. package/src/anthropic-auth-plugin.test.ts +37 -2
  78. package/src/anthropic-auth-plugin.ts +9 -7
  79. package/src/anthropic-auth-state.ts +1 -0
  80. package/src/bin.ts +7 -3
  81. package/src/bot-lifecycle.test.ts +252 -0
  82. package/src/cache-drift-plugin.ts +34 -24
  83. package/src/cache-rewrite.test.ts +49 -0
  84. package/src/cache-rewrite.ts +96 -0
  85. package/src/cli-commands/send.ts +3 -9
  86. package/src/cli-commands/session.ts +48 -8
  87. package/src/cli-parsing.test.ts +3 -3
  88. package/src/cli-runner.ts +11 -0
  89. package/src/cli-send-thread.e2e.test.ts +2 -2
  90. package/src/cli.ts +6 -6
  91. package/src/commands/abort.ts +20 -4
  92. package/src/commands/ask-question.ts +88 -25
  93. package/src/commands/queue.ts +7 -7
  94. package/src/database.ts +105 -6
  95. package/src/db.test.ts +124 -0
  96. package/src/discord-bot.ts +264 -186
  97. package/src/discord-utils.ts +17 -1
  98. package/src/external-directory-permissions.e2e.test.ts +5 -5
  99. package/src/external-directory-project-deny.e2e.test.ts +2 -2
  100. package/src/forum-sync/watchers.ts +20 -0
  101. package/src/gateway-proxy.e2e.test.ts +9 -9
  102. package/src/hrana-server.ts +96 -16
  103. package/src/markdown.test.ts +108 -22
  104. package/src/markdown.ts +216 -103
  105. package/src/message-formatting.test.ts +29 -5
  106. package/src/message-formatting.ts +30 -2
  107. package/src/opencode.ts +28 -13
  108. package/src/question-abort-clears-dropdown.e2e.test.ts +2 -2
  109. package/src/question-abort-resume.e2e.test.ts +121 -0
  110. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +2 -2
  111. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  112. package/src/queue-advanced-abort.e2e.test.ts +75 -8
  113. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  114. package/src/queue-advanced-e2e-setup.ts +65 -0
  115. package/src/queue-advanced-footer.e2e.test.ts +19 -19
  116. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  117. package/src/queue-advanced-permissions-typing.e2e.test.ts +3 -3
  118. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  119. package/src/queue-delete-message.e2e.test.ts +4 -4
  120. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  121. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  122. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  123. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  124. package/src/session-handler/event-stream-state.test.ts +969 -0
  125. package/src/session-handler/event-stream-state.ts +262 -3
  126. package/src/session-handler/thread-session-runtime.ts +257 -42
  127. package/src/sleep.e2e.test.ts +9 -9
  128. package/src/store.ts +2 -2
  129. package/src/system-message.test.ts +20 -10
  130. package/src/system-message.ts +9 -10
  131. package/src/task-runner.ts +11 -3
  132. package/src/task-schedule.test.ts +1 -1
  133. package/src/task-schedule.ts +2 -2
  134. package/src/test-utils.ts +5 -3
  135. package/src/thread-message-queue.e2e.test.ts +31 -32
  136. package/src/undo-redo.e2e.test.ts +4 -4
  137. package/src/voice-message.e2e.test.ts +70 -26
  138. package/src/voice-routing-input.e2e.test.ts +4 -4
  139. package/src/voice-routing-question.e2e.test.ts +4 -4
  140. package/src/voice.test.ts +56 -0
  141. package/src/voice.ts +135 -25
  142. package/src/wait-session.ts +25 -2
  143. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, expect, test } from 'vitest';
5
+ import { countSystemPromptDiffLines, formatSystemPromptPatch, writeSystemPromptPatch, } from './cache-rewrite.js';
6
+ const tempDirs = [];
7
+ afterEach(() => {
8
+ for (const dir of tempDirs.splice(0)) {
9
+ fs.rmSync(dir, { recursive: true, force: true });
10
+ }
11
+ });
12
+ test('counts added and removed system prompt lines', () => {
13
+ expect(countSystemPromptDiffLines({
14
+ beforeText: 'keep\nold line\n',
15
+ afterText: 'keep\nnew line\nextra\n',
16
+ })).toEqual({ additions: 2, deletions: 1 });
17
+ });
18
+ test('writes a unified patch only for the caller', async () => {
19
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-cache-rewrite-'));
20
+ tempDirs.push(dataDir);
21
+ const filePath = await writeSystemPromptPatch({
22
+ dataDir,
23
+ sessionId: 'ses_test',
24
+ beforeText: 'alpha\n',
25
+ afterText: 'beta\n',
26
+ model: 'openai/gpt-5',
27
+ agent: 'build',
28
+ now: 1_700_000_000_000,
29
+ });
30
+ expect(path.basename(filePath)).toBe('1700000000000-ses_test.patch');
31
+ const text = fs.readFileSync(filePath, 'utf8');
32
+ expect(text).toContain('session: ses_test');
33
+ expect(text).toContain('system +1 -1');
34
+ expect(text).toContain('-alpha');
35
+ expect(text).toContain('+beta');
36
+ expect(formatSystemPromptPatch({
37
+ beforeText: 'alpha\n',
38
+ afterText: 'beta\n',
39
+ sessionId: 'ses_test',
40
+ })).toContain('--- system-before.txt');
41
+ });
@@ -6,7 +6,6 @@
6
6
  import { goke } from 'goke';
7
7
  import { z } from 'zod';
8
8
  import { note } from '@clack/prompts';
9
- import YAML from 'yaml';
10
9
  import * as errore from 'errore';
11
10
  import { Events, ActivityType, Routes } from 'discord.js';
12
11
  import path from 'node:path';
@@ -24,7 +23,7 @@ import { WORKTREE_PREFIX } from '../commands/merge-worktree.js';
24
23
  import { QUEUE_PREFIX } from '../message-formatting.js';
25
24
  import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-event-log.js';
26
25
  import { createDiscordRest } from '../discord-urls.js';
27
- import { archiveThread, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
26
+ import { archiveThread, buildThreadStartEmbeds, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
28
27
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
29
28
  import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js';
30
29
  import { upgrade, getCurrentVersion } from '../upgrade.js';
@@ -405,12 +404,7 @@ cli
405
404
  ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
406
405
  ...(options.parentSession && { parentSessionId: options.parentSession }),
407
406
  };
408
- const promptEmbed = [
409
- {
410
- color: 0x2b2d31,
411
- footer: { text: YAML.stringify(threadPromptMarker) },
412
- },
413
- ];
407
+ const promptEmbed = await buildThreadStartEmbeds(threadPromptMarker);
414
408
  // Prefix the prompt so it's clear who sent it (matches /queue format).
415
409
  // Use a newline between prefix and prompt so leading /command
416
410
  // detection can find the command on its own line.
@@ -584,7 +578,7 @@ cli
584
578
  ...(options.parentSession && { parentSessionId: options.parentSession }),
585
579
  };
586
580
  const autoStartEmbed = embedMarker
587
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
581
+ ? await buildThreadStartEmbeds(embedMarker)
588
582
  : undefined;
589
583
  const starterMessage = await sendDiscordMessageWithOptionalAttachment({
590
584
  channelId,
@@ -106,17 +106,27 @@ cli
106
106
  process.exit(EXIT_NO_RESTART);
107
107
  }
108
108
  const client = getClient();
109
- const [sessionsResponse, statusResponse] = await Promise.all([
109
+ const [sessionsResponse, statusResponse, questionsResponse] = await Promise.all([
110
110
  client.session.list(),
111
111
  client.session.status({ directory: projectDirectory }).catch(() => null),
112
+ client.question.list({ directory: projectDirectory }).catch(() => null),
112
113
  ]);
113
114
  const statuses = statusResponse?.data || {};
115
+ const sessionsWithPendingQuestion = new Set((questionsResponse?.data || []).map((request) => request.sessionID));
114
116
  for (const session of sessionsResponse.data || []) {
115
117
  const status = statuses[session.id];
118
+ const isBusy = Boolean(status && status.type !== 'idle');
119
+ // Only relabel to showing-question when the session is actually busy.
120
+ // An orphaned question left in the list after an abort (session idle)
121
+ // must not hide or relabel an otherwise-idle session.
116
122
  gathered.push({
117
123
  session,
118
124
  projectDirectory,
119
- status: status && status.type !== 'idle' ? 'busy' : 'idle',
125
+ status: isBusy && sessionsWithPendingQuestion.has(session.id)
126
+ ? 'showing-question'
127
+ : isBusy
128
+ ? 'busy'
129
+ : 'idle',
120
130
  });
121
131
  }
122
132
  }
@@ -195,7 +205,12 @@ cli
195
205
  : '';
196
206
  const updatedAt = new Date(session.time.updated).toISOString();
197
207
  const threadInfo = threadId ? ` | thread: ${threadId}` : '';
198
- const statusInfo = ` | status: ${entry.status === 'busy' ? 'working' : 'idle'}`;
208
+ const statusLabel = entry.status === 'busy'
209
+ ? 'working'
210
+ : entry.status === 'showing-question'
211
+ ? 'showing-question'
212
+ : 'idle';
213
+ const statusInfo = ` | status: ${statusLabel}`;
199
214
  const tokens = contextInfo(entry);
200
215
  const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
201
216
  console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
@@ -281,9 +296,18 @@ cli
281
296
  }
282
297
  });
283
298
  cli
284
- .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
299
+ .command('session read <sessionId>', dedent `
300
+ Read a session conversation as markdown (pipe to file to grep).
301
+
302
+ Thinking is omitted by default. Tool inputs are truncated. Use
303
+ \`--thinking\` and \`--verbose\` for the full dump.
304
+ `)
285
305
  .option('--project <path>', 'Project directory (defaults to cwd)')
286
306
  .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
307
+ .option('--thinking', 'Include reasoning / thinking parts')
308
+ .option('--tool-input-max-chars <n>', z.number().default(80).describe('Max characters for compact tool input'))
309
+ .example('kimaki session read ses_xxx > ./tmp/session.md')
310
+ .example('kimaki session read ses_xxx --thinking --verbose')
287
311
  .action(async (sessionId, options) => {
288
312
  try {
289
313
  const projectDirectory = path.resolve(options.project || '.');
@@ -297,7 +321,12 @@ cli
297
321
  // Try current project first (fast path)
298
322
  const compactTools = !options.verbose;
299
323
  const markdown = new ShareMarkdown(getClient());
300
- const result = await markdown.generate({ sessionID: sessionId, compactTools });
324
+ const result = await markdown.generate({
325
+ sessionID: sessionId,
326
+ compactTools,
327
+ includeThinking: options.thinking,
328
+ toolInputMaxChars: options.toolInputMaxChars,
329
+ });
301
330
  if (!(result instanceof Error)) {
302
331
  process.stdout.write(result);
303
332
  process.exit(0);
@@ -331,6 +360,8 @@ cli
331
360
  const otherResult = await otherMarkdown.generate({
332
361
  sessionID: sessionId,
333
362
  compactTools,
363
+ includeThinking: options.thinking,
364
+ toolInputMaxChars: options.toolInputMaxChars,
334
365
  });
335
366
  if (!(otherResult instanceof Error)) {
336
367
  process.stdout.write(otherResult);
@@ -346,7 +377,7 @@ cli
346
377
  }
347
378
  });
348
379
  cli
349
- .command('session wait <sessionId>', 'Wait for a session to finish, then print its conversation as markdown')
380
+ .command('session wait <sessionId>', 'Wait until a session finishes or pauses for a user question, then print its conversation as markdown')
350
381
  .action(async (sessionId) => {
351
382
  try {
352
383
  await initDatabase();
@@ -50,7 +50,7 @@ async function parseRootBotOptions(argv) {
50
50
  const script = [
51
51
  "import { goke } from 'goke'",
52
52
  'const cli = goke(\'kimaki\')',
53
- "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--skip-footer-mentions', 'Do not mention the thread creator in final footers')",
53
+ "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
54
54
  `const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
55
55
  'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
56
56
  ].join(';');
@@ -213,13 +213,13 @@ describe('goke CLI ID parsing', () => {
213
213
  'node',
214
214
  'kimaki',
215
215
  '--no-analytics',
216
- '--skip-footer-mentions',
216
+ '--enable-footer-mentions',
217
217
  ]);
218
218
  expect(result.options).toMatchInlineSnapshot(`
219
219
  {
220
220
  "--": [],
221
+ "enableFooterMentions": true,
221
222
  "noAnalytics": true,
222
- "skipFooterMentions": true,
223
223
  }
224
224
  `);
225
225
  });
@@ -2,7 +2,7 @@
2
2
  // Keeps cli.ts focused on command composition while preserving the bot onboarding flow.
3
3
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
4
4
  import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
5
- import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
5
+ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, registerBotLifecycleHandlers, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
6
6
  import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getDb, } from './database.js';
7
7
  import * as orm from 'drizzle-orm';
8
8
  import * as dbSchema from './schema.js';
@@ -1175,6 +1175,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1175
1175
  cliLogger.error('Failed to start hrana server:', hranaResult.message);
1176
1176
  process.exit(EXIT_NO_RESTART);
1177
1177
  }
1178
+ // We own the lock port from here on. Stop signals must release it in every
1179
+ // startup phase (Discord login, channel reconcile), not only after ready.
1180
+ registerBotLifecycleHandlers();
1178
1181
  // Initialize database (connects to hrana server via HTTP)
1179
1182
  await initDatabase();
1180
1183
  const { appId, token, credentialSource, isGatewayMode, installerDiscordUserId } = await resolveCredentials({
@@ -1316,6 +1319,10 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1316
1319
  }
1317
1320
  process.exit(EXIT_NO_RESTART);
1318
1321
  }
1322
+ // Stop signal during Discord login: shutdown owns the process now.
1323
+ if (global.shuttingDown) {
1324
+ return;
1325
+ }
1319
1326
  await setBotToken(appId, token);
1320
1327
  // In gateway mode the bot only sees guilds the user has installed
1321
1328
  // it in. Zero guilds means the install URL callback never completed or the
@@ -1351,6 +1358,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1351
1358
  // Start bot immediately — channel sync happens in the background.
1352
1359
  cliLogger.log('Starting Discord bot...');
1353
1360
  await startDiscordBot({ token, appId, discordClient, useWorktrees });
1361
+ if (global.shuttingDown) {
1362
+ return;
1363
+ }
1354
1364
  cliLogger.log('Discord bot is running!');
1355
1365
  // Background channel sync + role reconciliation + default channel creation.
1356
1366
  // Never blocks ready state.
@@ -319,9 +319,9 @@ describe('kimaki send --channel thread creation', () => {
319
319
  "--- from: user (cli-send-tester)
320
320
  thread empty content seed
321
321
  --- from: assistant (TestBot)
322
- > *using deterministic-provider/deterministic-v2*
322
+ -# *using deterministic-provider/deterministic-v2*
323
323
  > caught-by-model
324
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000830>
324
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
325
325
  --- from: user (cli-send-tester)
326
326
  --- from: assistant (TestBot)
327
327
  I can see you sent a message, but Discord did not include its text.
package/dist/cli.js CHANGED
@@ -39,7 +39,7 @@ cli
39
39
  .option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
40
40
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
41
41
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
42
- .option('--skip-footer-mentions', 'Do not mention the thread creator in final session footers')
42
+ .option('--enable-footer-mentions', 'Mention the thread creator in final session footers so they get a notification')
43
43
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
44
44
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
45
45
  .option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
@@ -176,7 +176,7 @@ cli
176
176
  }),
177
177
  ...(options.mentionMode && { defaultMentionMode: true }),
178
178
  ...(options.noCritique && { critiqueEnabled: false }),
179
- ...(options.skipFooterMentions && { footerMentionsEnabled: false }),
179
+ ...(options.enableFooterMentions && { footerMentionsEnabled: true }),
180
180
  ...(options.allowAllUsers && { allowAllUsers: true }),
181
181
  ...(options.restrictDirectories && { restrictExternalDirectories: true }),
182
182
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
@@ -212,8 +212,8 @@ cli
212
212
  if (options.noCritique) {
213
213
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
214
214
  }
215
- if (options.skipFooterMentions) {
216
- cliLogger.log('Footer mentions disabled: final session footers will not mention thread creators');
215
+ if (options.enableFooterMentions) {
216
+ cliLogger.log('Footer mentions enabled: final session footers will mention thread creators');
217
217
  }
218
218
  if (options.noAutoUpgrade) {
219
219
  cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
@@ -1,6 +1,6 @@
1
1
  // /abort command - Abort the current OpenCode request in this thread.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
- import { cancelSessionSleepForThread, getThreadSession } from '../database.js';
3
+ import { cancelSessionSleepForThread, deleteThreadQueueItems, getThreadSession, } from '../database.js';
4
4
  import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
@@ -45,12 +45,21 @@ export async function handleAbortCommand({ command, }) {
45
45
  // through enqueueIncoming, so cancel any pending sleep here too. Otherwise the
46
46
  // wake would still fire later and restart a session the user just stopped.
47
47
  await cancelSessionSleepForThread({ threadId: channel.id });
48
- // abortActiveRun delegates to session.abort(), run settlement stays event-driven.
48
+ // abortActiveRun delegates to session.abort() and clears the /queue, so
49
+ // queued messages are not sent after abort or restored after a restart.
49
50
  const runtime = getRuntime(channel.id);
51
+ let clearedCount = 0;
50
52
  if (runtime) {
51
- runtime.abortActiveRun('user-requested');
53
+ const cleared = await runtime.abortActiveRun('user-requested');
54
+ clearedCount = cleared.length;
52
55
  }
53
56
  else {
57
+ const deleteResult = await deleteThreadQueueItems(channel.id).catch((error) => {
58
+ return new Error('Failed to clear persisted queue', { cause: error });
59
+ });
60
+ if (deleteResult instanceof Error) {
61
+ logger.error(`[ABORT] ${deleteResult.message}:`, deleteResult.cause);
62
+ }
54
63
  // No runtime but session exists — fall back to direct API abort
55
64
  const serverResult = await initializeOpencodeForDirectory(projectDirectory);
56
65
  if (serverResult instanceof Error) {
@@ -67,6 +76,9 @@ export async function handleAbortCommand({ command, }) {
67
76
  logger.error('[ABORT] API abort failed:', error);
68
77
  }
69
78
  }
70
- await command.editReply('Request **aborted**');
79
+ const queueNote = clearedCount > 0
80
+ ? `, cleared ${clearedCount} queued message${clearedCount > 1 ? 's' : ''}`
81
+ : '';
82
+ await command.editReply(`Request **aborted**${queueNote}`);
71
83
  logger.log(`Session ${sessionId} aborted by user`);
72
84
  }
@@ -8,6 +8,7 @@ import { getOpencodeClient } from '../opencode.js';
8
8
  import { DiscordOperationError } from '../errors.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { QUEUE_PREFIX } from '../message-formatting.js';
11
+ import { getRuntime } from '../session-handler/thread-session-runtime.js';
11
12
  const logger = createLogger(LogPrefix.ASK_QUESTION);
12
13
  // Store pending question contexts by hash.
13
14
  // No TTL on purpose: a question can only be answered by the user, so expiring it
@@ -185,41 +186,89 @@ export async function handleAskQuestionSelectMenu(interaction) {
185
186
  });
186
187
  const username = interaction.user.globalName || interaction.user.username;
187
188
  await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
188
- // Check if all questions are answered
189
- if (areAllQuestionsAnswered(context)) {
190
- // All questions answered - send result back to session
191
- await submitQuestionAnswers(context);
189
+ // Check if all questions are answered. Claim the context synchronously
190
+ // (delete before awaiting submit) so two concurrent final selections for a
191
+ // multi-question tool cannot both submit and double-resume the session.
192
+ if (areAllQuestionsAnswered(context)
193
+ && pendingQuestionContexts.get(contextHash) === context) {
192
194
  deletePendingQuestionContextsForRequest({
193
195
  threadId: context.thread.id,
194
196
  requestId: context.requestId,
195
197
  });
198
+ await submitQuestionAnswers(context);
199
+ }
200
+ }
201
+ /**
202
+ * Format collected answers as a plain-text summary the model can read when
203
+ * the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
204
+ *
205
+ * Known limitation: if the user had queued items via /queue during the pending
206
+ * question AND aborted the run from another opencode client, the first queued
207
+ * item may have already been handed off to opencode and can lose its ordering
208
+ * on resume. That rare combination needs a queue-handoff redesign; the common
209
+ * (no-queue) abort case is handled correctly.
210
+ */
211
+ function formatQuestionAnswersText(context) {
212
+ const parts = context.questions.map((q, i) => {
213
+ const answer = (context.answers[i] || []).join(', ') || 'Unanswered';
214
+ return `"${q.question}"="${answer}"`;
215
+ });
216
+ return `Answers to your previous questions: ${parts.join(', ')}`;
217
+ }
218
+ /** Resume the session by feeding the answers back as a new user prompt. */
219
+ async function resumeSessionWithAnswers(context) {
220
+ const runtime = getRuntime(context.thread.id);
221
+ const resumed = await runtime?.resumeWithText({
222
+ text: formatQuestionAnswersText(context),
223
+ });
224
+ if (!resumed) {
225
+ await sendThreadMessage(context.thread, '✗ Failed to submit answers: session is no longer active');
196
226
  }
197
227
  }
198
228
  /**
199
229
  * Submit all collected answers back to the OpenCode session.
200
- * Uses the question.reply API to provide answers to the waiting tool.
230
+ *
231
+ * The decision is based on whether the session still has a live run:
232
+ *
233
+ * - Busy: a live run is parked on the question. Reply so it continues.
234
+ * - Idle: the run was aborted (from this or another opencode client). Replying
235
+ * would resolve a dead run and the session would never continue, so resume it
236
+ * with the answers as a fresh prompt instead.
237
+ *
238
+ * Note: `question.list` is not a reliable signal here. After an abort the
239
+ * question request can stay in the pending list but orphaned (no run awaiting
240
+ * it), so a reply would succeed yet the session would still not continue. The
241
+ * session busy state, derived from the event stream, is the accurate signal.
242
+ * The reply `.error` check is a safety net for the case where the request was
243
+ * actually removed while the session still looked busy.
201
244
  */
202
245
  async function submitQuestionAnswers(context) {
203
- try {
204
- const client = getOpencodeClient(context.directory);
205
- if (!client) {
206
- throw new Error('OpenCode server not found for directory');
207
- }
208
- // Build answers array: each element is an array of selected labels for that question
209
- const answers = context.questions.map((_, i) => {
210
- return context.answers[i] || [];
211
- });
212
- await client.question.reply({
213
- requestID: context.requestId,
214
- directory: context.directory,
215
- answers,
216
- });
217
- logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
246
+ const client = getOpencodeClient(context.directory);
247
+ if (!client) {
248
+ logger.error('OpenCode server not found for directory');
249
+ return;
218
250
  }
219
- catch (error) {
220
- logger.error('Failed to submit answers:', error);
221
- await sendThreadMessage(context.thread, `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`);
251
+ const runtime = getRuntime(context.thread.id);
252
+ if (runtime && !runtime.isBusy()) {
253
+ logger.log(`Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`);
254
+ await resumeSessionWithAnswers(context);
255
+ return;
256
+ }
257
+ const answers = context.questions.map((_, i) => {
258
+ return context.answers[i] || [];
259
+ });
260
+ // throwOnError is off on this client, so failures come back in `.error`.
261
+ const replyResult = await client.question
262
+ .reply({ requestID: context.requestId, directory: context.directory, answers })
263
+ .catch((error) => ({ error }));
264
+ if (!replyResult.error) {
265
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
266
+ return;
222
267
  }
268
+ // Reply failed (the request was removed while the session still looked busy).
269
+ // Resume so the answer still reaches the model.
270
+ logger.log(`Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`);
271
+ await resumeSessionWithAnswers(context);
223
272
  }
224
273
  /**
225
274
  * Check if a tool part is an AskUserQuestion tool.
@@ -5,7 +5,7 @@ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
- import { QUEUE_PREFIX } from '../message-formatting.js';
8
+ import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
9
9
  import { store } from '../store.js';
10
10
  const logger = createLogger(LogPrefix.QUEUE);
11
11
  export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
@@ -75,7 +75,7 @@ export async function handleQueueRemoveButton(interaction) {
75
75
  : await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
76
76
  if (!removed) {
77
77
  await interaction.editReply({
78
- content: 'Queued message is no longer in the queue',
78
+ content: asSubtext('Queued message is no longer in the queue'),
79
79
  components: [],
80
80
  });
81
81
  return;
@@ -84,7 +84,7 @@ export async function handleQueueRemoveButton(interaction) {
84
84
  ? `/${removed.command.name}`
85
85
  : removed.prompt.slice(0, 120);
86
86
  await interaction.editReply({
87
- content: `Removed queued message: ${label}`,
87
+ content: asSubtext(`Removed queued message: ${label}`),
88
88
  components: [],
89
89
  });
90
90
  logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
@@ -145,7 +145,7 @@ export async function handleQueueCommand({ command, appId, }) {
145
145
  mode: 'local-queue',
146
146
  });
147
147
  if (enqueueResult.queued && enqueueResult.queueId) {
148
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`;
148
+ const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
149
149
  await command.reply({
150
150
  content: responseText,
151
151
  components: [
@@ -205,7 +205,7 @@ export async function handleClearQueueCommand({ command, }) {
205
205
  return;
206
206
  }
207
207
  await command.reply({
208
- content: `Cleared queued message at position ${position}`,
208
+ content: asSubtext(`Cleared queued message at position ${position}`),
209
209
  flags: SILENT_MESSAGE_FLAGS,
210
210
  });
211
211
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
@@ -223,7 +223,7 @@ export async function handleClearQueueCommand({ command, }) {
223
223
  list = list.slice(0, 597) + '...';
224
224
  }
225
225
  await command.reply({
226
- content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
226
+ content: asSubtext(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
227
227
  flags: SILENT_MESSAGE_FLAGS,
228
228
  });
229
229
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
@@ -299,7 +299,7 @@ export async function handleQueueCommandCommand({ command, appId, }) {
299
299
  mode: 'local-queue',
300
300
  });
301
301
  if (enqueueResult.queued && enqueueResult.queueId) {
302
- const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`;
302
+ const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
303
303
  await command.reply({
304
304
  content: responseText,
305
305
  components: [
package/dist/database.js CHANGED
@@ -174,13 +174,13 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
174
174
  export async function deleteScheduledTask(taskId) {
175
175
  const db = await getDb();
176
176
  await db.delete(schema.scheduled_tasks)
177
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
177
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
178
178
  }
179
179
  export async function markScheduledTaskCronRescheduled({ taskId, completedAt, nextRunAt }) {
180
180
  const db = await getDb();
181
181
  await db.update(schema.scheduled_tasks)
182
182
  .set({ status: 'planned', last_run_at: completedAt, running_started_at: null, last_error: null, next_run_at: nextRunAt })
183
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
183
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
184
184
  }
185
185
  export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }) {
186
186
  const db = await getDb();
@@ -192,7 +192,7 @@ export async function markScheduledTaskFailed({ taskId, failedAt, errorMessage }
192
192
  last_error: errorMessage,
193
193
  attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
194
194
  })
195
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
195
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
196
196
  }
197
197
  export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessage, nextRunAt }) {
198
198
  const db = await getDb();
@@ -205,7 +205,7 @@ export async function markScheduledTaskCronRetry({ taskId, failedAt, errorMessag
205
205
  last_error: errorMessage,
206
206
  attempts: orm.sql `${schema.scheduled_tasks.attempts} + 1`,
207
207
  })
208
- .where(orm.eq(schema.scheduled_tasks.id, taskId));
208
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
209
209
  }
210
210
  export async function setSessionStartSource({ sessionId, scheduleKind, scheduledTaskId }) {
211
211
  const db = await getDb();
@@ -913,20 +913,87 @@ export async function deleteChannelDirectoriesByDirectory(directory) {
913
913
  const db = await getDb();
914
914
  await db.delete(schema.channel_directories).where(orm.eq(schema.channel_directories.directory, directory));
915
915
  }
916
- export async function deleteChannelDirectoryById(channelId) {
916
+ export async function deleteChannelDirectoryById(channelId, { preserveMapping = false } = {}) {
917
917
  const db = await getDb();
918
918
  await db.batch([
919
+ db.update(schema.scheduled_tasks)
920
+ .set({
921
+ status: 'cancelled',
922
+ running_started_at: null,
923
+ last_error: 'Discord channel was deleted',
924
+ })
925
+ .where(orm.and(orm.eq(schema.scheduled_tasks.channel_id, channelId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
919
926
  db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, channelId)),
920
927
  db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, channelId)),
921
928
  db.delete(schema.channel_worktrees).where(orm.eq(schema.channel_worktrees.channel_id, channelId)),
922
929
  db.delete(schema.channel_verbosity).where(orm.eq(schema.channel_verbosity.channel_id, channelId)),
923
930
  db.delete(schema.channel_mention_mode).where(orm.eq(schema.channel_mention_mode.channel_id, channelId)),
924
931
  ]);
932
+ if (preserveMapping) {
933
+ return Boolean(await db.query.channel_directories.findFirst({
934
+ where: { channel_id: channelId },
935
+ columns: { channel_id: true },
936
+ }));
937
+ }
925
938
  const rows = await db.delete(schema.channel_directories)
926
939
  .where(orm.eq(schema.channel_directories.channel_id, channelId))
927
940
  .returning({ channel_id: schema.channel_directories.channel_id });
928
941
  return rows.length > 0;
929
942
  }
943
+ export async function cleanupDeletedThread(threadId) {
944
+ const db = await getDb();
945
+ const binding = await db.query.thread_sessions.findFirst({
946
+ where: { thread_id: threadId },
947
+ columns: { session_id: true },
948
+ });
949
+ const currentThreadId = binding
950
+ ? await getThreadIdBySessionId(binding.session_id)
951
+ : undefined;
952
+ const currentSessionId = currentThreadId === threadId
953
+ ? binding?.session_id
954
+ : undefined;
955
+ await db.batch([
956
+ db.delete(schema.thread_queue_items)
957
+ .where(orm.eq(schema.thread_queue_items.thread_id, threadId)),
958
+ db.update(schema.session_sleeps)
959
+ .set({ status: 'cancelled' })
960
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, currentSessionId ?? ''))),
961
+ db.update(schema.scheduled_tasks)
962
+ .set({
963
+ status: 'cancelled',
964
+ running_started_at: null,
965
+ last_error: 'Discord thread was deleted',
966
+ })
967
+ .where(orm.and(orm.eq(schema.scheduled_tasks.thread_id, threadId), orm.inArray(schema.scheduled_tasks.status, ['planned', 'running']))),
968
+ db.update(schema.ipc_requests)
969
+ .set({
970
+ status: 'cancelled',
971
+ response: JSON.stringify({ error: 'Discord thread was deleted' }),
972
+ })
973
+ .where(orm.and(orm.eq(schema.ipc_requests.thread_id, threadId), orm.inArray(schema.ipc_requests.status, ['pending', 'processing']))),
974
+ ]);
975
+ }
976
+ export async function isCurrentThreadSessionBinding(threadId) {
977
+ const db = await getDb();
978
+ const binding = await db.query.thread_sessions.findFirst({
979
+ where: { thread_id: threadId },
980
+ columns: { session_id: true },
981
+ });
982
+ if (!binding)
983
+ return false;
984
+ return await getThreadIdBySessionId(binding.session_id) === threadId;
985
+ }
986
+ export async function clearGuildCategoryByChannelId(channelId) {
987
+ const db = await getDb();
988
+ await db.batch([
989
+ db.update(schema.guild_categories)
990
+ .set({ category_id: null })
991
+ .where(orm.eq(schema.guild_categories.category_id, channelId)),
992
+ db.update(schema.guild_categories)
993
+ .set({ audio_category_id: null })
994
+ .where(orm.eq(schema.guild_categories.audio_category_id, channelId)),
995
+ ]);
996
+ }
930
997
  export async function getVoiceChannelDirectory(channelId) {
931
998
  const db = await getDb();
932
999
  return (await db.query.channel_directories.findFirst({ where: { channel_id: channelId, channel_type: 'voice' } }))?.directory;
@@ -982,7 +1049,7 @@ export async function completeIpcRequest({ id, response }) {
982
1049
  const db = await getDb();
983
1050
  const [row] = await db.update(schema.ipc_requests)
984
1051
  .set({ response, status: 'completed' })
985
- .where(orm.eq(schema.ipc_requests.id, id))
1052
+ .where(orm.and(orm.eq(schema.ipc_requests.id, id), orm.inArray(schema.ipc_requests.status, ['pending', 'processing'])))
986
1053
  .returning();
987
1054
  return row;
988
1055
  }