kimaki 0.30.1 → 0.31.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 (111) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +2 -2
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/btw.js +147 -39
  10. package/dist/commands/context-usage.js +47 -0
  11. package/dist/commands/context-usage.test.js +49 -0
  12. package/dist/commands/fork.js +32 -1
  13. package/dist/commands/model.js +69 -14
  14. package/dist/commands/new-worktree.js +5 -2
  15. package/dist/commands/queue.js +32 -25
  16. package/dist/commands/session.js +7 -2
  17. package/dist/db.test.js +17 -7
  18. package/dist/discord-bot.js +65 -42
  19. package/dist/errors.js +10 -5
  20. package/dist/external-directory-permissions.e2e.test.js +2 -2
  21. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  22. package/dist/gateway-proxy.e2e.test.js +5 -5
  23. package/dist/message-formatting.js +34 -9
  24. package/dist/message-formatting.test.js +2 -2
  25. package/dist/message-preprocessing.js +53 -75
  26. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  27. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  28. package/dist/queue-advanced-footer.e2e.test.js +7 -7
  29. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  30. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  31. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  32. package/dist/queue-delete-message.e2e.test.js +146 -5
  33. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  34. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  35. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  36. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  37. package/dist/session-handler/agent-utils.js +16 -17
  38. package/dist/session-handler/event-stream-state.js +10 -2
  39. package/dist/session-handler/event-stream-state.test.js +129 -3
  40. package/dist/session-handler/thread-session-runtime.js +169 -101
  41. package/dist/sleep.e2e.test.js +4 -4
  42. package/dist/system-message.js +214 -392
  43. package/dist/system-message.test.js +204 -380
  44. package/dist/test-utils.js +32 -6
  45. package/dist/thread-message-queue.e2e.test.js +131 -59
  46. package/dist/undo-redo.e2e.test.js +2 -2
  47. package/dist/voice-handler.js +2 -1
  48. package/dist/voice-message.e2e.test.js +22 -31
  49. package/dist/voice-routing-input.e2e.test.js +2 -2
  50. package/dist/voice-routing-question.e2e.test.js +59 -2
  51. package/dist/voice.js +306 -206
  52. package/dist/voice.test.js +161 -13
  53. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  54. package/package.json +8 -11
  55. package/skills/critique/SKILL.md +4 -0
  56. package/skills/opensrc/SKILL.md +20 -13
  57. package/skills/playwriter/SKILL.md +1 -1
  58. package/src/agent-model.e2e.test.ts +28 -19
  59. package/src/analytics.ts +3 -1
  60. package/src/btw-prefix-detection.test.ts +39 -1
  61. package/src/btw-prefix-detection.ts +11 -0
  62. package/src/cli-commands/send.ts +8 -2
  63. package/src/cli-runner.test.ts +37 -0
  64. package/src/cli-runner.ts +26 -10
  65. package/src/cli-send-thread.e2e.test.ts +1 -1
  66. package/src/commands/btw.ts +151 -41
  67. package/src/commands/context-usage.test.ts +57 -0
  68. package/src/commands/context-usage.ts +59 -0
  69. package/src/commands/fork.ts +34 -1
  70. package/src/commands/model.ts +81 -14
  71. package/src/commands/new-worktree.ts +5 -2
  72. package/src/commands/queue.ts +41 -25
  73. package/src/commands/session.ts +10 -2
  74. package/src/db.test.ts +18 -6
  75. package/src/discord-bot.ts +67 -51
  76. package/src/errors.ts +12 -6
  77. package/src/external-directory-permissions.e2e.test.ts +2 -2
  78. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  79. package/src/gateway-proxy.e2e.test.ts +5 -5
  80. package/src/message-formatting.test.ts +2 -2
  81. package/src/message-formatting.ts +37 -10
  82. package/src/message-preprocessing.ts +69 -91
  83. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  84. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  85. package/src/queue-advanced-footer.e2e.test.ts +7 -7
  86. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  87. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  88. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  89. package/src/queue-delete-message.e2e.test.ts +164 -4
  90. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  91. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  92. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  93. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  94. package/src/session-handler/agent-utils.ts +25 -20
  95. package/src/session-handler/event-stream-state.test.ts +129 -2
  96. package/src/session-handler/event-stream-state.ts +21 -2
  97. package/src/session-handler/thread-runtime-state.ts +7 -0
  98. package/src/session-handler/thread-session-runtime.ts +211 -123
  99. package/src/sleep.e2e.test.ts +4 -4
  100. package/src/system-message.test.ts +216 -379
  101. package/src/system-message.ts +253 -389
  102. package/src/test-utils.ts +55 -12
  103. package/src/thread-message-queue.e2e.test.ts +135 -62
  104. package/src/undo-redo.e2e.test.ts +2 -2
  105. package/src/voice-handler.ts +2 -1
  106. package/src/voice-message.e2e.test.ts +22 -33
  107. package/src/voice-routing-input.e2e.test.ts +2 -2
  108. package/src/voice-routing-question.e2e.test.ts +59 -2
  109. package/src/voice.test.ts +164 -14
  110. package/src/voice.ts +400 -279
  111. package/src/worktree-lifecycle.e2e.test.ts +7 -7
@@ -6,6 +6,45 @@ import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.j
6
6
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  const logger = createLogger(LogPrefix.SESSION);
9
+ export function formatContextBreakdown({ messages, lastAssistantId, inputTokens, }) {
10
+ if (inputTokens <= 0)
11
+ return undefined;
12
+ const lastIndex = messages.findIndex((message) => message.info.id === lastAssistantId);
13
+ if (lastIndex < 0)
14
+ return undefined;
15
+ const history = messages.slice(0, lastIndex);
16
+ const compactIndex = history.findLastIndex((message) => message.parts.some((part) => part.type === 'compaction'));
17
+ const active = history.slice(compactIndex < 0 ? 0 : compactIndex + 1);
18
+ const system = [...active].reverse().find((message) => message.info.role === 'user' && message.info.system);
19
+ const systemChars = system?.info.role === 'user' ? system.info.system?.length ?? 0 : 0;
20
+ const toolChars = new Map();
21
+ for (const message of active) {
22
+ if (message.info.role !== 'assistant')
23
+ continue;
24
+ for (const part of message.parts) {
25
+ if (part.type !== 'tool')
26
+ continue;
27
+ const output = part.state.status === 'completed' && !part.state.time.compacted
28
+ ? part.state.output
29
+ : part.state.status === 'error' ? part.state.error : '';
30
+ const chars = JSON.stringify(part.state.input).length + output.length;
31
+ toolChars.set(part.tool, (toolChars.get(part.tool) ?? 0) + chars);
32
+ }
33
+ }
34
+ const estimates = [
35
+ { name: 'system', tokens: Math.ceil(systemChars / 4) },
36
+ ...[...toolChars].map(([name, chars]) => ({ name: `tool ${name}`, tokens: Math.ceil(chars / 4) })),
37
+ ].sort((a, b) => b.tokens - a.tokens);
38
+ const estimated = estimates.reduce((total, entry) => total + entry.tokens, 0);
39
+ const scale = estimated > inputTokens ? inputTokens / estimated : 1;
40
+ const visible = estimates.slice(0, 5).map((entry) => ({
41
+ name: entry.name,
42
+ tokens: Math.floor(entry.tokens * scale),
43
+ }));
44
+ const other = inputTokens - visible.reduce((total, entry) => total + entry.tokens, 0);
45
+ const format = ({ name, tokens }) => `${name} ${(tokens / inputTokens * 100).toFixed(1)}% (${tokens.toLocaleString('en-US')})`;
46
+ return `**Estimated input mix:** ${[...visible.filter((entry) => entry.tokens > 0), { name: 'other', tokens: other }].map(format).join(' · ')} tokens (other includes messages and unexposed prompts)`;
47
+ }
9
48
  function getTokenTotal({ input, output, reasoning, cache, }) {
10
49
  return input + output + reasoning + cache.read + cache.write;
11
50
  }
@@ -96,6 +135,7 @@ export async function handleContextUsageCommand({ command, }) {
96
135
  }
97
136
  const { tokens, modelID, providerID } = lastAssistant.info;
98
137
  const totalTokens = getTokenTotal(tokens);
138
+ const inputTokens = tokens.input + tokens.cache.read + tokens.cache.write;
99
139
  // Sum cost across all assistant messages for accurate session total
100
140
  // (AssistantMessage.cost is per-message, not cumulative)
101
141
  const totalCost = assistantMessages.reduce((sum, m) => {
@@ -129,6 +169,13 @@ export async function handleContextUsageCommand({ command, }) {
129
169
  else {
130
170
  lines.push(`**Context usage:** ${formattedTokens} tokens (context limit unavailable)`);
131
171
  }
172
+ const breakdown = formatContextBreakdown({
173
+ messages,
174
+ lastAssistantId: lastAssistant.info.id,
175
+ inputTokens,
176
+ });
177
+ if (breakdown)
178
+ lines.push(breakdown);
132
179
  if (modelID) {
133
180
  lines.push(`**Model:** ${modelID}`);
134
181
  }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { formatContextBreakdown } from './context-usage.js';
3
+ const part = (messageID, value) => ({
4
+ id: `part-${messageID}`,
5
+ sessionID: 'session',
6
+ messageID,
7
+ ...value,
8
+ });
9
+ const user = (id, system) => ({
10
+ id, sessionID: 'session', role: 'user', time: { created: 1 },
11
+ agent: 'build', model: { providerID: 'test', modelID: 'test' }, system,
12
+ });
13
+ const assistant = (id, input) => ({
14
+ id, sessionID: 'session', role: 'assistant', time: { created: 2 },
15
+ parentID: 'user', modelID: 'test', providerID: 'test', mode: 'build',
16
+ agent: 'build', path: { cwd: '/', root: '/' }, cost: 0,
17
+ tokens: { input, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
18
+ });
19
+ describe('formatContextBreakdown', () => {
20
+ test('attributes tool call inputs and outputs by type, and excludes the response to the measured prompt', () => {
21
+ const messages = [
22
+ { info: user('user', 's'.repeat(40)), parts: [part('user', { type: 'text', text: 'u'.repeat(20) })] },
23
+ { info: assistant('first', 20), parts: [
24
+ part('first', { type: 'tool', tool: 'read', callID: 'call', state: {
25
+ status: 'completed', input: { filePath: 'a'.repeat(28) }, output: 'o'.repeat(40),
26
+ title: 'read', metadata: {}, time: { start: 1, end: 2 },
27
+ } }),
28
+ ] },
29
+ { info: assistant('last', 50), parts: [part('last', { type: 'text', text: 'x'.repeat(800) })] },
30
+ ];
31
+ expect(formatContextBreakdown({ messages, lastAssistantId: 'last', inputTokens: 50 }))
32
+ .toMatchInlineSnapshot(`"**Estimated input mix:** tool read 42.0% (21) · system 20.0% (10) · other 38.0% (19) tokens (other includes messages and unexposed prompts)"`);
33
+ });
34
+ test('ignores compacted history and old tool outputs', () => {
35
+ const messages = [
36
+ { info: user('old', 'x'.repeat(800)), parts: [part('old', { type: 'text', text: 'x'.repeat(800) })] },
37
+ { info: assistant('old-answer', 30), parts: [part('old-answer', { type: 'tool', tool: 'bash', callID: 'old', state: {
38
+ status: 'completed', input: {}, output: 'x'.repeat(800), title: 'old', metadata: {},
39
+ time: { start: 1, end: 2, compacted: 3 },
40
+ } })] },
41
+ { info: user('compact'), parts: [part('compact', { type: 'compaction', auto: true })] },
42
+ { info: assistant('summary', 20), parts: [part('summary', { type: 'text', text: 'summary'.repeat(8) })] },
43
+ { info: user('new', 's'.repeat(20)), parts: [part('new', { type: 'text', text: 'u'.repeat(20) })] },
44
+ { info: assistant('last', 40), parts: [] },
45
+ ];
46
+ expect(formatContextBreakdown({ messages, lastAssistantId: 'last', inputTokens: 40 }))
47
+ .toMatchInlineSnapshot(`"**Estimated input mix:** system 12.5% (5) · other 87.5% (35) tokens (other includes messages and unexposed prompts)"`);
48
+ });
49
+ });
@@ -5,6 +5,8 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, sendSessionPartBatches, } from '../discord-utils.js';
6
6
  import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
+ import { copySessionPreferences } from './model.js';
9
+ import { copySessionSystemPrompt } from '../system-message.js';
8
10
  import * as errore from 'errore';
9
11
  const sessionLogger = createLogger(LogPrefix.SESSION);
10
12
  const forkLogger = createLogger(LogPrefix.FORK);
@@ -224,7 +226,36 @@ export async function handleForkSelectMenu(interaction) {
224
226
  });
225
227
  // Claim the forked session immediately so external polling does not race
226
228
  // and create a duplicate Sync thread before the rest of this setup runs.
227
- await setThreadSession(thread.id, forkedSession.id);
229
+ // Same agent, model and pinned system prompt keep the source prompt cache.
230
+ const [, , copiedSystem] = await Promise.all([
231
+ setThreadSession(thread.id, forkedSession.id),
232
+ copySessionPreferences({
233
+ sourceSessionId: sessionId,
234
+ targetSessionId: forkedSession.id,
235
+ channelId: textChannel.id,
236
+ appId: interaction.applicationId,
237
+ getClient,
238
+ directory: projectDirectory,
239
+ }),
240
+ copySessionSystemPrompt({
241
+ sourceSessionId: sessionId,
242
+ targetSessionId: forkedSession.id,
243
+ }),
244
+ ]);
245
+ // `false` (source not pinned yet) is the only fallback; I/O errors fail setup.
246
+ if (copiedSystem instanceof Error) {
247
+ forkLogger.error(`Could not pin source system prompt on fork ${forkedSession.id}:`, copiedSystem);
248
+ await Promise.all([
249
+ thread.delete('fork setup failed').catch((error) => {
250
+ forkLogger.warn(`Could not delete orphan fork thread ${thread.id}:`, error);
251
+ }),
252
+ getClient().session.delete({ sessionID: forkedSession.id }).catch((error) => {
253
+ forkLogger.warn(`Could not delete orphan fork session ${forkedSession.id}:`, error);
254
+ }),
255
+ ]);
256
+ await interaction.editReply(`Failed to fork session: could not copy the source system prompt (${copiedSystem.message})`);
257
+ return;
258
+ }
228
259
  // Add user to thread so it appears in their sidebar
229
260
  await thread.members.add(interaction.user.id);
230
261
  const sourceThreadLink = `<#${threadChannel.id}>`;
@@ -6,9 +6,11 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
7
  import { getDefaultModel, resolveDisplayedModelId, } from '../session-handler/model-utils.js';
8
8
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
9
+ import { resolveAgentPreference } from '../session-handler/agent-utils.js';
9
10
  import { getThinkingValuesForModel } from '../thinking-utils.js';
10
11
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
11
12
  import { createLogger, LogPrefix } from '../logger.js';
13
+ import { OpenCodeSdkError } from '../errors.js';
12
14
  import * as errore from 'errore';
13
15
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
14
16
  const modelLogger = createLogger(LogPrefix.MODEL);
@@ -123,27 +125,80 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
123
125
  });
124
126
  modelLogger.log(`[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`);
125
127
  }
126
- export async function copyCurrentSessionModel({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
127
- const modelInfo = await getCurrentModelInfo({
128
- sessionId: sourceSessionId,
129
- channelId,
130
- appId,
131
- getClient,
132
- directory,
133
- });
128
+ const LAST_USER_AGENT_PAGE_SIZE = 50;
129
+ const LAST_USER_AGENT_MAX_PAGES = 10;
130
+ /**
131
+ * Agent of the newest user message in a session, paging back from the end.
132
+ * Long agentic turns hold many assistant messages after one user message.
133
+ */
134
+ async function getLastUserMessageAgent({ getClient, sessionId, directory, before, page = 0, }) {
135
+ if (page >= LAST_USER_AGENT_MAX_PAGES)
136
+ return undefined;
137
+ const response = await (async () => {
138
+ return getClient().session.messages({
139
+ sessionID: sessionId,
140
+ directory,
141
+ limit: LAST_USER_AGENT_PAGE_SIZE,
142
+ before,
143
+ });
144
+ })().catch((cause) => new OpenCodeSdkError({ operation: 'session.messages', cause }));
145
+ if (response instanceof Error)
146
+ return response;
147
+ if (response.error || !response.data) {
148
+ return new OpenCodeSdkError({ operation: 'session.messages', cause: response.error });
149
+ }
150
+ const newestUser = response.data
151
+ .map((message) => message.info)
152
+ .filter((info) => info.role === 'user')
153
+ .sort((a, b) => b.time.created - a.time.created)[0];
154
+ if (newestUser?.role === 'user')
155
+ return newestUser.agent;
156
+ const cursor = response.response.headers.get('X-Next-Cursor');
157
+ if (!cursor)
158
+ return undefined;
159
+ return getLastUserMessageAgent({ getClient, sessionId, directory, before: cursor, page: page + 1 });
160
+ }
161
+ /**
162
+ * Copy the agent, model and variant onto a forked session. The fork must run
163
+ * the same agent and model as the source: a different agent changes tools and
164
+ * prompts, which busts the prompt cache. The agent comes from the fork's own
165
+ * history (the fork point, also right for /fork at an earlier message), with
166
+ * the source DB preference as fallback.
167
+ */
168
+ export async function copySessionPreferences({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
169
+ const [historyAgent, preferredAgent, modelInfo, variant] = await Promise.all([
170
+ getClient instanceof Error
171
+ ? undefined
172
+ : getLastUserMessageAgent({ getClient, sessionId: targetSessionId, directory }),
173
+ resolveAgentPreference({ sessionId: sourceSessionId, channelId }),
174
+ getCurrentModelInfo({
175
+ sessionId: sourceSessionId,
176
+ channelId,
177
+ appId,
178
+ getClient,
179
+ directory,
180
+ }),
181
+ getVariantCascade({
182
+ sessionId: sourceSessionId,
183
+ channelId,
184
+ appId,
185
+ }),
186
+ ]);
187
+ if (historyAgent instanceof Error) {
188
+ modelLogger.warn(`[MODEL] Could not read fork history agent for ${targetSessionId}, using DB preference: ${historyAgent.message}`);
189
+ }
190
+ const agent = (historyAgent instanceof Error ? undefined : historyAgent) || preferredAgent;
191
+ if (agent) {
192
+ await setSessionAgent(targetSessionId, agent);
193
+ }
134
194
  if (modelInfo.type === 'none')
135
195
  return;
136
- const variant = await getVariantCascade({
137
- sessionId: sourceSessionId,
138
- channelId,
139
- appId,
140
- });
141
196
  await setSessionModel({
142
197
  sessionId: targetSessionId,
143
198
  modelId: modelInfo.model,
144
199
  variant: variant ?? null,
145
200
  });
146
- modelLogger.log(`[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
201
+ modelLogger.log(`[MODEL] Copied session agent ${agent ?? 'default'} and model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
147
202
  }
148
203
  /**
149
204
  * Get the current model info for a channel/session, including where it comes from.
@@ -15,7 +15,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
15
15
  import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
16
16
  import { WORKTREE_PREFIX } from './merge-worktree.js';
17
17
  import * as errore from 'errore';
18
- import { copyCurrentSessionModel } from './model.js';
18
+ import { copySessionPreferences } from './model.js';
19
19
  const logger = createLogger(LogPrefix.WORKTREE);
20
20
  async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
21
21
  if (!rawBaseBranch) {
@@ -551,7 +551,10 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
551
551
  await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
552
552
  return;
553
553
  }
554
- await copyCurrentSessionModel({
554
+ // No system prompt copy: OpenCode's env block (cwd) precedes the kimaki
555
+ // system prompt, so a fork into another directory never shares the
556
+ // source cache prefix. The fork pins its own prompt on its first turn.
557
+ await copySessionPreferences({
555
558
  sourceSessionId,
556
559
  targetSessionId: forkedSession.id,
557
560
  channelId: parent.id,
@@ -6,6 +6,7 @@ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils
6
6
  import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
9
+ import { extractBtwQueueSuffix } from '../btw-prefix-detection.js';
9
10
  import { store } from '../store.js';
10
11
  const logger = createLogger(LogPrefix.QUEUE);
11
12
  export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
@@ -52,6 +53,19 @@ function buildQueueRemoveRow({ threadId, queueId, }) {
52
53
  .setLabel('Remove from queue')
53
54
  .setStyle(ButtonStyle.Secondary));
54
55
  }
56
+ // Replies "Queued message" and returns the ack id, so the drain indicator can
57
+ // reply to it. The runtime calls this inside the serialized enqueue.
58
+ function replyQueuedAck({ command, threadId, }) {
59
+ return async ({ queueId, position }) => {
60
+ await command.reply({
61
+ content: asSubtext(`Queued message (position ${position})`),
62
+ components: [buildQueueRemoveRow({ threadId, queueId })],
63
+ flags: SILENT_MESSAGE_FLAGS,
64
+ });
65
+ const ack = await command.fetchReply();
66
+ return ack.id;
67
+ };
68
+ }
55
69
  export async function handleQueueRemoveButton(interaction) {
56
70
  const parsed = parseQueueRemoveCustomId(interaction.customId);
57
71
  if (!parsed) {
@@ -90,8 +104,18 @@ export async function handleQueueRemoveButton(interaction) {
90
104
  logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
91
105
  }
92
106
  export async function handleQueueCommand({ command, appId, }) {
93
- const message = command.options.getString('message', true);
107
+ // /queue always queues; a typed suffix is redundant, so only strip it.
108
+ const rawMessage = command.options.getString('message', true);
109
+ const btw = extractBtwQueueSuffix(rawMessage);
110
+ const message = btw.prompt;
94
111
  const channel = command.channel;
112
+ if (!message.trim()) {
113
+ await command.reply({
114
+ content: 'Write a message to queue',
115
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
116
+ });
117
+ return;
118
+ }
95
119
  if (!channel) {
96
120
  await command.reply({
97
121
  content: 'This command can only be used in a channel',
@@ -139,23 +163,15 @@ export async function handleQueueCommand({ command, appId, }) {
139
163
  // /queue explicitly uses kimaki local queue mode.
140
164
  const enqueueResult = await runtime.enqueueIncoming({
141
165
  prompt: message,
166
+ queuedAction: btw.forceBtw ? 'btw' : undefined,
142
167
  userId: command.user.id,
143
168
  username: command.user.displayName,
144
169
  appId,
145
170
  mode: 'local-queue',
171
+ onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
146
172
  });
147
- if (enqueueResult.queued && enqueueResult.queueId) {
148
- const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
149
- await command.reply({
150
- content: responseText,
151
- components: [
152
- buildQueueRemoveRow({
153
- threadId: thread.id,
154
- queueId: enqueueResult.queueId,
155
- }),
156
- ],
157
- flags: SILENT_MESSAGE_FLAGS,
158
- });
173
+ // onLocalQueued already posted the ack.
174
+ if (enqueueResult.queued) {
159
175
  return;
160
176
  }
161
177
  const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`;
@@ -297,19 +313,10 @@ export async function handleQueueCommandCommand({ command, appId, }) {
297
313
  appId,
298
314
  command: commandPayload,
299
315
  mode: 'local-queue',
316
+ onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
300
317
  });
301
- if (enqueueResult.queued && enqueueResult.queueId) {
302
- const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
303
- await command.reply({
304
- content: responseText,
305
- components: [
306
- buildQueueRemoveRow({
307
- threadId: thread.id,
308
- queueId: enqueueResult.queueId,
309
- }),
310
- ],
311
- flags: SILENT_MESSAGE_FLAGS,
312
- });
318
+ // onLocalQueued already posted the ack.
319
+ if (enqueueResult.queued) {
313
320
  logger.log(`[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`);
314
321
  return;
315
322
  }
@@ -12,6 +12,7 @@ import { sendThreadMessage, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, resol
12
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
13
13
  import { createLogger, LogPrefix } from '../logger.js';
14
14
  import { DiscordOperationError } from '../errors.js';
15
+ import { extractQueueSuffix } from '../message-formatting.js';
15
16
  const logger = createLogger(LogPrefix.SESSION);
16
17
  // Create only: callers confirm the destination before dispatching the full prompt.
17
18
  export async function createNewSessionThread({ textChannel, projectDirectory, sourceWorkspace, prompt, files = [], userId, }) {
@@ -54,10 +55,14 @@ export async function createNewSessionThread({ textChannel, projectDirectory, so
54
55
  }
55
56
  export async function handleSessionCommand({ command, appId, }) {
56
57
  await command.deferReply();
57
- const prompt = command.options.getString('prompt', true);
58
+ const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
58
59
  const filesString = command.options.getString('files') || '';
59
60
  const agent = command.options.getString('agent') || undefined;
60
61
  const channel = command.channel;
62
+ if (!prompt.trim()) {
63
+ await command.editReply('Write a prompt for the new session');
64
+ return;
65
+ }
61
66
  const isThread = channel && [
62
67
  ChannelType.PublicThread,
63
68
  ChannelType.PrivateThread,
@@ -139,7 +144,7 @@ export async function handleSessionCommand({ command, appId, }) {
139
144
  username: command.user.displayName,
140
145
  agent,
141
146
  appId,
142
- mode: 'opencode',
147
+ mode: forceQueue ? 'local-queue' : 'opencode',
143
148
  }).catch(async (error) => {
144
149
  logger.error('New session dispatch failed:', error);
145
150
  await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
package/dist/db.test.js CHANGED
@@ -7,11 +7,11 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionAgent, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionAgent, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
11
11
  import { createClient } from '@libsql/client';
12
12
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
13
13
  import { chooseLockPort } from './test-utils.js';
14
- import { copyCurrentSessionModel } from './commands/model.js';
14
+ import { copySessionPreferences } from './commands/model.js';
15
15
  afterAll(async () => {
16
16
  await closeDb();
17
17
  });
@@ -566,7 +566,9 @@ describe('getDb', () => {
566
566
  await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
567
567
  await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
568
568
  });
569
- test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
569
+ // Regression: btw forks lost the agent and ran `build` instead of the source
570
+ // agent, which changed tools and busted the whole prompt cache.
571
+ test('copySessionPreferences snapshots source session agent and model to forked session', async () => {
570
572
  const db = await getDb();
571
573
  const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
572
574
  const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
@@ -578,18 +580,26 @@ describe('getDb', () => {
578
580
  modelId: 'anthropic/claude-opus-4-6',
579
581
  variant: 'thinking',
580
582
  });
581
- await copyCurrentSessionModel({
583
+ await setSessionAgent(sourceSessionId, 'opus');
584
+ await copySessionPreferences({
582
585
  sourceSessionId,
583
586
  targetSessionId,
584
587
  getClient,
585
588
  });
586
- await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
589
+ expect({
590
+ agent: await getSessionAgent(targetSessionId),
591
+ model: await getSessionModel(targetSessionId),
592
+ }).toMatchInlineSnapshot(`
587
593
  {
588
- "modelId": "anthropic/claude-opus-4-6",
589
- "variant": "thinking",
594
+ "agent": "opus",
595
+ "model": {
596
+ "modelId": "anthropic/claude-opus-4-6",
597
+ "variant": "thinking",
598
+ },
590
599
  }
591
600
  `);
592
601
  await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
602
+ await db.delete(schema.session_agents).where(orm.inArray(schema.session_agents.session_id, [sourceSessionId, targetSessionId]));
593
603
  });
594
604
  test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
595
605
  const db = await getDb();