kimaki 0.24.0 → 0.26.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 +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/cli.js +5 -0
  8. package/dist/cloudflare-transcription.js +56 -0
  9. package/dist/commands/agent.js +87 -74
  10. package/dist/commands/model-variant.js +17 -1
  11. package/dist/commands/model.js +18 -0
  12. package/dist/commands/multioauth.js +169 -1
  13. package/dist/commands/queue.js +64 -0
  14. package/dist/commands/tasks.js +6 -0
  15. package/dist/commands/unset-model.js +5 -20
  16. package/dist/config.js +9 -0
  17. package/dist/database.js +65 -0
  18. package/dist/discord-bot.js +8 -6
  19. package/dist/discord-command-registration.js +17 -12
  20. package/dist/external-directory-permissions.e2e.test.js +82 -0
  21. package/dist/external-directory-project-deny.e2e.test.js +68 -0
  22. package/dist/format-tables.js +53 -5
  23. package/dist/format-tables.test.js +121 -0
  24. package/dist/interaction-handler.js +8 -6
  25. package/dist/ipc-tools-plugin.js +4 -3
  26. package/dist/kimaki-opencode-plugin.js +2 -0
  27. package/dist/message-formatting.js +84 -5
  28. package/dist/message-formatting.test.js +160 -2
  29. package/dist/message-preprocessing.js +2 -25
  30. package/dist/oauth-rotation-shared.js +7 -1
  31. package/dist/openai-auth-state.js +20 -0
  32. package/dist/opencode.js +126 -96
  33. package/dist/queue-advanced-e2e-setup.js +63 -27
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
  35. package/dist/schema.js +19 -0
  36. package/dist/session-handler/event-stream-state.js +147 -0
  37. package/dist/session-handler/event-stream-state.test.js +437 -1
  38. package/dist/session-handler/thread-session-runtime.js +95 -57
  39. package/dist/store.js +1 -0
  40. package/dist/subagent-rate-limit-plugin.js +13 -2
  41. package/dist/system-message.js +18 -4
  42. package/dist/system-message.test.js +28 -4
  43. package/dist/task-runner.js +143 -12
  44. package/dist/task-schedule.js +12 -0
  45. package/dist/task-schedule.test.js +149 -2
  46. package/dist/voice-handler.js +37 -11
  47. package/dist/xai-auth-plugin.js +237 -0
  48. package/dist/xai-auth-state.js +233 -0
  49. package/dist/xai-auth-state.test.js +183 -0
  50. package/package.json +6 -6
  51. package/skills/profano/SKILL.md +5 -3
  52. package/skills/spiceflow/SKILL.md +24 -1
  53. package/src/agent-model.e2e.test.ts +172 -4
  54. package/src/analytics.test.ts +30 -1
  55. package/src/analytics.ts +1 -0
  56. package/src/cli-commands/send.ts +22 -3
  57. package/src/cli-commands/task.ts +32 -19
  58. package/src/cli-runner.test.ts +16 -1
  59. package/src/cli-runner.ts +88 -89
  60. package/src/cli.ts +11 -0
  61. package/src/cloudflare-transcription.ts +76 -0
  62. package/src/commands/agent.ts +120 -84
  63. package/src/commands/model-variant.ts +26 -0
  64. package/src/commands/model.ts +25 -0
  65. package/src/commands/multioauth.ts +195 -2
  66. package/src/commands/queue.ts +83 -0
  67. package/src/commands/tasks.ts +6 -0
  68. package/src/commands/unset-model.ts +5 -21
  69. package/src/config.ts +10 -0
  70. package/src/database.ts +124 -0
  71. package/src/discord-bot.ts +7 -6
  72. package/src/discord-command-registration.ts +22 -15
  73. package/src/external-directory-permissions.e2e.test.ts +99 -0
  74. package/src/external-directory-project-deny.e2e.test.ts +82 -0
  75. package/src/format-tables.test.ts +126 -0
  76. package/src/format-tables.ts +68 -5
  77. package/src/interaction-handler.ts +10 -5
  78. package/src/ipc-tools-plugin.ts +4 -3
  79. package/src/kimaki-opencode-plugin.ts +2 -0
  80. package/src/message-formatting.test.ts +197 -2
  81. package/src/message-formatting.ts +98 -5
  82. package/src/message-preprocessing.ts +2 -37
  83. package/src/oauth-rotation-shared.ts +6 -1
  84. package/src/openai-auth-state.ts +19 -0
  85. package/src/opencode.ts +132 -111
  86. package/src/queue-advanced-e2e-setup.ts +78 -29
  87. package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
  88. package/src/schema.sql +15 -0
  89. package/src/schema.ts +20 -0
  90. package/src/session-handler/event-stream-state.test.ts +490 -0
  91. package/src/session-handler/event-stream-state.ts +210 -0
  92. package/src/session-handler/model-utils.ts +1 -0
  93. package/src/session-handler/thread-runtime-state.ts +0 -4
  94. package/src/session-handler/thread-session-runtime.ts +108 -69
  95. package/src/store.ts +10 -0
  96. package/src/subagent-rate-limit-plugin.ts +12 -2
  97. package/src/system-message.test.ts +29 -4
  98. package/src/system-message.ts +20 -4
  99. package/src/task-runner.ts +168 -11
  100. package/src/task-schedule.test.ts +172 -2
  101. package/src/task-schedule.ts +23 -2
  102. package/src/voice-handler.ts +37 -10
  103. package/src/xai-auth-plugin.ts +302 -0
  104. package/src/xai-auth-state.test.ts +224 -0
  105. package/src/xai-auth-state.ts +293 -0
  106. package/dist/channel-reference-permissions.e2e.test.js +0 -87
  107. package/dist/commands/add-dir.js +0 -169
  108. package/dist/commands/add-dir.test.js +0 -126
  109. package/src/channel-reference-permissions.e2e.test.ts +0 -104
  110. package/src/commands/add-dir.test.ts +0 -154
  111. package/src/commands/add-dir.ts +0 -244
package/dist/config.js CHANGED
@@ -70,6 +70,15 @@ export function setProjectsDir(dir) {
70
70
  export function getPermissionTimeoutMs() {
71
71
  return store.getState().permissionTimeoutMs;
72
72
  }
73
+ /**
74
+ * Whether external directory access is restricted to the session working
75
+ * directory plus a few known-safe paths.
76
+ * Defaults to false: every directory is allowed and users protect specific
77
+ * folders with their own `deny`/`ask` rules in opencode.json.
78
+ */
79
+ export function getRestrictExternalDirectories() {
80
+ return store.getState().restrictExternalDirectories;
81
+ }
73
82
  const DEFAULT_LOCK_PORT = 29988;
74
83
  /**
75
84
  * Derive a lock port from the data directory path.
package/dist/database.js CHANGED
@@ -95,6 +95,71 @@ export async function claimScheduledTaskRunning({ taskId, startedAt }) {
95
95
  .returning({ id: schema.scheduled_tasks.id });
96
96
  return countRows(rows) > 0;
97
97
  }
98
+ export async function releaseScheduledTaskClaim(taskId) {
99
+ const db = await getDb();
100
+ await db.update(schema.scheduled_tasks)
101
+ .set({ status: 'planned', running_started_at: null })
102
+ .where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
103
+ }
104
+ export async function createScheduledTaskRun({ taskId, startedAt, }) {
105
+ const db = await getDb();
106
+ const [run] = await db.insert(schema.scheduled_task_runs)
107
+ .values({ scheduled_task_id: taskId, started_at: startedAt })
108
+ .returning({ id: schema.scheduled_task_runs.id });
109
+ if (!run)
110
+ throw new Error(`Failed to create run for scheduled task ${taskId}`);
111
+ return run.id;
112
+ }
113
+ export async function getActiveScheduledTaskRuns(taskId) {
114
+ const db = await getDb();
115
+ return db.query.scheduled_task_runs.findMany({
116
+ where: {
117
+ scheduled_task_id: taskId,
118
+ status: { in: ['pending', 'running'] },
119
+ },
120
+ orderBy: { started_at: 'desc' },
121
+ });
122
+ }
123
+ export async function setScheduledTaskRunThread({ runId, threadId, }) {
124
+ const db = await getDb();
125
+ await db.update(schema.scheduled_task_runs)
126
+ .set({ thread_id: threadId })
127
+ .where(orm.eq(schema.scheduled_task_runs.id, runId));
128
+ }
129
+ export async function startScheduledTaskRunSession({ runId, sessionId, projectDirectory, }) {
130
+ const db = await getDb();
131
+ await db.update(schema.scheduled_task_runs)
132
+ .set({ status: 'running', session_id: sessionId, project_directory: projectDirectory })
133
+ .where(orm.and(orm.eq(schema.scheduled_task_runs.id, runId), orm.eq(schema.scheduled_task_runs.status, 'pending')));
134
+ }
135
+ export async function completeScheduledTaskRunsForSession(sessionId) {
136
+ const db = await getDb();
137
+ const rows = await db.update(schema.scheduled_task_runs)
138
+ .set({ status: 'completed', completed_at: new Date() })
139
+ .where(orm.and(orm.eq(schema.scheduled_task_runs.session_id, sessionId), orm.inArray(schema.scheduled_task_runs.status, ['pending', 'running'])))
140
+ .returning({ id: schema.scheduled_task_runs.id });
141
+ return rows.length;
142
+ }
143
+ export async function failScheduledTaskRunsForSession({ sessionId, error, }) {
144
+ const db = await getDb();
145
+ const rows = await db.update(schema.scheduled_task_runs)
146
+ .set({ status: 'failed', completed_at: new Date(), error })
147
+ .where(orm.and(orm.eq(schema.scheduled_task_runs.session_id, sessionId), orm.inArray(schema.scheduled_task_runs.status, ['pending', 'running'])))
148
+ .returning({ id: schema.scheduled_task_runs.id });
149
+ return rows.length;
150
+ }
151
+ export async function failScheduledTaskRun({ runId, error, }) {
152
+ const db = await getDb();
153
+ await db.update(schema.scheduled_task_runs)
154
+ .set({ status: 'failed', completed_at: new Date(), error })
155
+ .where(orm.eq(schema.scheduled_task_runs.id, runId));
156
+ }
157
+ export async function finishScheduledTaskRun({ runId, status, }) {
158
+ const db = await getDb();
159
+ await db.update(schema.scheduled_task_runs)
160
+ .set({ status, completed_at: new Date() })
161
+ .where(orm.eq(schema.scheduled_task_runs.id, runId));
162
+ }
98
163
  export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
99
164
  const db = await getDb();
100
165
  const rows = await db.update(schema.scheduled_tasks)
@@ -20,7 +20,7 @@ import { getFileAttachments, getTextAttachments, resolveMentions, } from './mess
20
20
  import { extractBtwSuffix } from './btw-prefix-detection.js';
21
21
  import { isVoiceAttachment } from './voice-attachment.js';
22
22
  import { forkSessionToBtwThread } from './commands/btw.js';
23
- import { extractQueueSuffix, getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
23
+ import { extractQueueSuffix, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
24
24
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
25
25
  import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
26
26
  import { cancelPendingFileUpload } from './commands/file-upload.js';
@@ -142,11 +142,15 @@ function parseSessionStartSourceFromMarker(marker) {
142
142
  if (typeof marker.scheduledTaskId !== 'number' ||
143
143
  !Number.isInteger(marker.scheduledTaskId) ||
144
144
  marker.scheduledTaskId < 1) {
145
- return { scheduleKind: marker.scheduledKind };
145
+ return {
146
+ scheduleKind: marker.scheduledKind,
147
+ scheduledTaskRunId: marker.scheduledTaskRunId,
148
+ };
146
149
  }
147
150
  return {
148
151
  scheduleKind: marker.scheduledKind,
149
152
  scheduledTaskId: marker.scheduledTaskId,
153
+ scheduledTaskRunId: marker.scheduledTaskRunId,
150
154
  };
151
155
  }
152
156
  export async function createDiscordClient() {
@@ -632,6 +636,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
632
636
  ? {
633
637
  scheduleKind: sessionStartSource.scheduleKind,
634
638
  scheduledTaskId: sessionStartSource.scheduledTaskId,
639
+ scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
635
640
  }
636
641
  : undefined,
637
642
  preprocess: () => {
@@ -1085,15 +1090,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1085
1090
  ? {
1086
1091
  scheduleKind: botThreadStartSource.scheduleKind,
1087
1092
  scheduledTaskId: botThreadStartSource.scheduledTaskId,
1093
+ scheduledTaskRunId: botThreadStartSource.scheduledTaskRunId,
1088
1094
  }
1089
1095
  : undefined,
1090
1096
  preprocess: async () => {
1091
- const permissionRules = await getChannelReferencePermissionRules({
1092
- message: starterMessage,
1093
- });
1094
1097
  return {
1095
1098
  prompt,
1096
- permissionRules,
1097
1099
  mode: 'opencode',
1098
1100
  ...(fileAttachments.length > 0 && { images: fileAttachments }),
1099
1101
  };
@@ -167,18 +167,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
167
167
  })
168
168
  .setDMPermission(false)
169
169
  .toJSON(),
170
- new SlashCommandBuilder()
171
- .setName('add-dir')
172
- .setDescription(truncateCommandDescription('Allow the current session to access an extra directory or * for all folders'))
173
- .addStringOption((option) => {
174
- option
175
- .setName('directory')
176
- .setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
177
- .setRequired(false);
178
- return option;
179
- })
180
- .setDMPermission(false)
181
- .toJSON(),
182
170
  new SlashCommandBuilder()
183
171
  .setName('abort')
184
172
  .setDescription(truncateCommandDescription('Abort, stop, terminate, or cancel the current OpenCode request in this thread'))
@@ -226,6 +214,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
226
214
  .setDescription(truncateCommandDescription('Set model preference, change thinking level, or clear overrides'))
227
215
  .setDMPermission(false)
228
216
  .toJSON(),
217
+ new SlashCommandBuilder()
218
+ .setName('model-variant')
219
+ .setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
220
+ .setDMPermission(false)
221
+ .toJSON(),
229
222
  new SlashCommandBuilder()
230
223
  .setName('login')
231
224
  .setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
@@ -248,6 +241,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
248
241
  })
249
242
  .setDMPermission(false)
250
243
  .toJSON(),
244
+ new SlashCommandBuilder()
245
+ .setName('clear-queue')
246
+ .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
247
+ .addIntegerOption((option) => {
248
+ option
249
+ .setName('position')
250
+ .setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
251
+ .setMinValue(1);
252
+ return option;
253
+ })
254
+ .setDMPermission(false)
255
+ .toJSON(),
251
256
  new SlashCommandBuilder()
252
257
  .setName('queue-command')
253
258
  .setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
@@ -0,0 +1,82 @@
1
+ // E2e test for the default external_directory permission: every directory is
2
+ // allowed, so reading a file outside the project never shows a permission
3
+ // prompt. The old behaviour (allow-list + prompt) is now opt-in behind the
4
+ // --restrict-directories CLI flag.
5
+ import { describe, expect, test } from 'vitest';
6
+ import fs from 'node:fs';
7
+ import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
8
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
9
+ const TEXT_CHANNEL_ID = '200000000000001021';
10
+ describe('external directory permissions', () => {
11
+ const ctx = setupQueueAdvancedSuite({
12
+ channelId: TEXT_CHANNEL_ID,
13
+ channelName: 'qa-external-directory-e2e',
14
+ dirName: 'qa-external-directory-e2e',
15
+ username: 'external-directory-tester',
16
+ });
17
+ test('reads outside the project without a permission prompt', async () => {
18
+ fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
19
+ fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'external directory file');
20
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
21
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER first',
22
+ });
23
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
24
+ timeout: 4_000,
25
+ predicate: (t) => {
26
+ return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
27
+ },
28
+ });
29
+ const th = ctx.discord.thread(thread.id);
30
+ await waitForBotMessageContaining({
31
+ discord: ctx.discord,
32
+ threadId: thread.id,
33
+ userId: TEST_USER_ID,
34
+ text: 'external-directory-probe-done',
35
+ timeout: 8_000,
36
+ });
37
+ await waitForFooterMessage({
38
+ discord: ctx.discord,
39
+ threadId: thread.id,
40
+ timeout: 4_000,
41
+ afterMessageIncludes: 'external-directory-probe-done',
42
+ afterAuthorId: ctx.discord.botUserId,
43
+ });
44
+ await th.user(TEST_USER_ID).sendMessage({
45
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER followup',
46
+ });
47
+ await waitForBotMessageContaining({
48
+ discord: ctx.discord,
49
+ threadId: thread.id,
50
+ userId: TEST_USER_ID,
51
+ text: 'external-directory-probe-done',
52
+ afterUserMessageIncludes: 'followup',
53
+ timeout: 8_000,
54
+ });
55
+ await waitForFooterMessage({
56
+ discord: ctx.discord,
57
+ threadId: thread.id,
58
+ timeout: 4_000,
59
+ afterMessageIncludes: 'external-directory-probe-done',
60
+ afterAuthorId: ctx.discord.botUserId,
61
+ });
62
+ const text = await th.text();
63
+ expect(text).toMatchInlineSnapshot(`
64
+ "--- from: user (external-directory-tester)
65
+ EXTERNAL_DIRECTORY_PROBE_MARKER first
66
+ --- from: assistant (TestBot)
67
+ *using deterministic-provider/deterministic-v2*
68
+ ⬥ reading external directory
69
+ ┣ read *probe.txt*
70
+ ⬥ external-directory-probe-done
71
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
72
+ --- from: user (external-directory-tester)
73
+ EXTERNAL_DIRECTORY_PROBE_MARKER followup
74
+ --- from: assistant (TestBot)
75
+ ⬥ reading external directory
76
+ ┣ read *probe.txt*
77
+ ⬥ external-directory-probe-done
78
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
79
+ `);
80
+ expect(text).not.toContain('Permission Required');
81
+ });
82
+ });
@@ -0,0 +1,68 @@
1
+ // Regression test: a `deny` rule in the project's own opencode.json must still
2
+ // win over kimaki's allow-all default.
3
+ //
4
+ // This guards a real bug. opencode evaluates permissions with findLast() over
5
+ // merge(agent.permission, session.permission), so session rules are evaluated
6
+ // LAST and override user config. An earlier version of the allow-all default
7
+ // put `external_directory: '*' allow` into the session ruleset, which silently
8
+ // made every user `deny` rule a no-op. The allow now lives in the generated
9
+ // server config instead, where the project opencode.json deep-merges on top.
10
+ import { describe, expect, test } from 'vitest';
11
+ import fs from 'node:fs';
12
+ import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
13
+ import { waitForBotMessageContaining } from './test-utils.js';
14
+ const TEXT_CHANNEL_ID = '200000000000001023';
15
+ describe('external directory project deny', () => {
16
+ const ctx = setupQueueAdvancedSuite({
17
+ channelId: TEXT_CHANNEL_ID,
18
+ channelName: 'qa-external-directory-deny-e2e',
19
+ dirName: 'qa-external-directory-deny-e2e',
20
+ username: 'external-directory-deny-tester',
21
+ // Exactly what a user would write in their own opencode.json to protect a
22
+ // folder. Kimaki's generated config allows '*', this must still beat it.
23
+ projectPermission: {
24
+ external_directory: {
25
+ [EXTERNAL_DIRECTORY_PROBE_DIR]: 'deny',
26
+ [`${EXTERNAL_DIRECTORY_PROBE_DIR}/*`]: 'deny',
27
+ },
28
+ },
29
+ });
30
+ test('project opencode.json deny beats the allow-all default', async () => {
31
+ fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
32
+ fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'protected file');
33
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
34
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER denied',
35
+ });
36
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
37
+ timeout: 4_000,
38
+ predicate: (t) => {
39
+ return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
40
+ },
41
+ });
42
+ const th = ctx.discord.thread(thread.id);
43
+ // The deterministic matcher only emits this after the read comes back
44
+ // rejected by a permission rule, so seeing it proves the deny applied.
45
+ await waitForBotMessageContaining({
46
+ discord: ctx.discord,
47
+ threadId: thread.id,
48
+ userId: TEST_USER_ID,
49
+ text: 'external-directory-probe-denied',
50
+ timeout: 8_000,
51
+ });
52
+ const text = await th.text();
53
+ expect(text).toMatchInlineSnapshot(`
54
+ "--- from: user (external-directory-deny-tester)
55
+ EXTERNAL_DIRECTORY_PROBE_MARKER denied
56
+ --- from: assistant (TestBot)
57
+ *using deterministic-provider/deterministic-v2*
58
+ ⬥ reading external directory
59
+ ┣ read *probe.txt*
60
+ ⬥ external-directory-probe-denied"
61
+ `);
62
+ // A deny is silent: it must not fall back to asking the user.
63
+ expect(text).not.toContain('Permission Required');
64
+ // And the read must not have succeeded, which is what the old
65
+ // session-level '*' allow rule caused.
66
+ expect(text).not.toContain('external-directory-probe-done');
67
+ });
68
+ });
@@ -267,7 +267,7 @@ function flattenCalloutChildren({ segments, }) {
267
267
  return [
268
268
  {
269
269
  type: ComponentType.TextDisplay,
270
- content: segment.text.trim(),
270
+ content: clampTextDisplayContent(segment.text.trim()),
271
271
  },
272
272
  ];
273
273
  }
@@ -279,15 +279,29 @@ function flattenCalloutChildren({ segments, }) {
279
279
  });
280
280
  });
281
281
  }
282
+ // Splits callout children into chunks that fit both the 40-component budget
283
+ // (reserving 1 for the Container wrapper itself, including nested children
284
+ // like ActionRow buttons via childComponentCost) and the 4000-char text
285
+ // budget. Mirrors chunkRowsByComponentLimit's approach for table rows.
282
286
  function chunkCalloutChildrenByComponentLimit({ children, }) {
283
287
  const chunks = [];
284
288
  let currentChunk = [];
289
+ let currentCost = 1; // reserve for the Container wrapper
290
+ let currentText = 0;
285
291
  for (const child of children) {
286
- if (currentChunk.length > 0 && currentChunk.length + 2 > MAX_COMPONENTS) {
292
+ const childCost = childComponentCost(child);
293
+ const childText = componentTextSize(child);
294
+ if (currentChunk.length > 0 &&
295
+ (currentCost + childCost > MAX_COMPONENTS ||
296
+ currentText + childText > MAX_TEXT_SIZE)) {
287
297
  chunks.push(currentChunk);
288
298
  currentChunk = [];
299
+ currentCost = 1;
300
+ currentText = 0;
289
301
  }
290
302
  currentChunk.push(child);
303
+ currentCost += childCost;
304
+ currentText += childText;
291
305
  }
292
306
  if (currentChunk.length > 0) {
293
307
  chunks.push(currentChunk);
@@ -423,16 +437,34 @@ function buildTextRow({ headers, cells, }) {
423
437
  const value = cell ? getRenderedCellText({ cell }) : '';
424
438
  return `**${key}** ${value}`;
425
439
  });
440
+ const content = clampTextDisplayContent(lines.join('\n'));
426
441
  return {
427
442
  components: [
428
443
  {
429
444
  type: ComponentType.TextDisplay,
430
- content: lines.join('\n'),
445
+ content,
431
446
  },
432
447
  ],
433
448
  componentCost: 1,
449
+ textSize: content.length,
434
450
  };
435
451
  }
452
+ // Discord's TextDisplay component enforces the same 4000-char content limit
453
+ // as the overall message text budget. A single row with one huge cell value
454
+ // (e.g. a long stack trace) could otherwise exceed it on its own, which no
455
+ // amount of row-level chunking can fix. `maxLength` lets callers reserve
456
+ // budget for sibling content (e.g. button labels) that shares the same
457
+ // message-wide text limit.
458
+ function clampTextDisplayContent(content, maxLength = MAX_TEXT_SIZE) {
459
+ const safeMax = Math.max(0, maxLength);
460
+ if (content.length <= safeMax) {
461
+ return content;
462
+ }
463
+ if (safeMax === 0) {
464
+ return '';
465
+ }
466
+ return content.slice(0, safeMax - 1) + '…';
467
+ }
436
468
  function buildButtonRow({ headers, cells, }) {
437
469
  const buttonCells = cells.filter((cell) => {
438
470
  return cell.type === 'button';
@@ -463,32 +495,48 @@ function buildButtonRow({ headers, cells, }) {
463
495
  type: ComponentType.ActionRow,
464
496
  components: buttons,
465
497
  };
498
+ const buttonLabelSize = buttonCells.reduce((sum, cell) => {
499
+ return sum + (cell.type === 'button' ? cell.label.length : 0);
500
+ }, 0);
501
+ // Reserve budget for button labels first since they can't be truncated,
502
+ // then clamp the TextDisplay content to whatever remains of MAX_TEXT_SIZE.
503
+ const content = clampTextDisplayContent(lines.join('\n'), MAX_TEXT_SIZE - buttonLabelSize);
466
504
  return {
467
505
  components: [
468
506
  {
469
507
  type: ComponentType.TextDisplay,
470
- content: lines.join('\n'),
508
+ content,
471
509
  },
472
510
  actionRow,
473
511
  ],
474
512
  componentCost: 2 + buttons.length,
513
+ textSize: content.length + buttonLabelSize,
475
514
  };
476
515
  }
516
+ // Splits rows into chunks that fit both the 40-component budget and the
517
+ // 4000-char displayable text budget. Wide/long tables (many long cell values)
518
+ // can stay under the component count while still exceeding Discord's text
519
+ // limit, which previously caused sendMessage to fail with a 400 error.
477
520
  function chunkRowsByComponentLimit({ rows, }) {
478
521
  const chunks = [];
479
522
  let currentChunk = [];
480
523
  let currentCost = 1;
524
+ let currentText = 0;
481
525
  for (const row of rows) {
482
526
  const separatorCost = currentChunk.length > 0 ? 1 : 0;
483
527
  const nextCost = currentCost + separatorCost + row.componentCost;
484
- if (currentChunk.length > 0 && nextCost > MAX_COMPONENTS) {
528
+ const nextText = currentText + row.textSize;
529
+ if (currentChunk.length > 0 &&
530
+ (nextCost > MAX_COMPONENTS || nextText > MAX_TEXT_SIZE)) {
485
531
  chunks.push(currentChunk);
486
532
  currentChunk = [row];
487
533
  currentCost = 1 + row.componentCost;
534
+ currentText = row.textSize;
488
535
  continue;
489
536
  }
490
537
  currentChunk.push(row);
491
538
  currentCost = nextCost;
539
+ currentText = nextText;
492
540
  }
493
541
  if (currentChunk.length > 0) {
494
542
  chunks.push(currentChunk);
@@ -205,6 +205,85 @@ describe('buildTableComponents', () => {
205
205
  ]
206
206
  `);
207
207
  });
208
+ test('splits table into multiple containers when text size exceeds 4000 chars even though component count fits', () => {
209
+ // 10 rows well under the 40-component budget (1 container + 10 TDs + 9 seps = 20),
210
+ // but each row has a long cell value so total text exceeds MAX_TEXT_SIZE (4000).
211
+ const header = '| Name | Description |';
212
+ const sep = '| --- | --- |';
213
+ const longValue = 'x'.repeat(500);
214
+ const rows = Array.from({ length: 10 }, (_, i) => {
215
+ return `| row-${i} | ${longValue} |`;
216
+ }).join('\n');
217
+ const table = parseTable(`${header}\n${sep}\n${rows}`);
218
+ const result = buildTableComponents(table);
219
+ // Total text across all rows (~5000+ chars) must not fit in a single
220
+ // container; it should be split across multiple component segments.
221
+ expect(result.length).toBeGreaterThan(1);
222
+ for (const segment of result) {
223
+ if (segment.type !== 'components')
224
+ continue;
225
+ const container = segment.components[0];
226
+ if (!container || container.type !== ComponentType.Container)
227
+ continue;
228
+ const totalText = container.components.reduce((sum, child) => {
229
+ if ('content' in child && typeof child.content === 'string') {
230
+ return sum + child.content.length;
231
+ }
232
+ return sum;
233
+ }, 0);
234
+ expect(totalText).toBeLessThanOrEqual(4000);
235
+ }
236
+ });
237
+ test('clamps a single row whose own content exceeds the 4000-char text limit', () => {
238
+ const header = '| Name | Description |';
239
+ const sep = '| --- | --- |';
240
+ const hugeValue = 'y'.repeat(5000);
241
+ const table = parseTable(`${header}\n${sep}\n| row | ${hugeValue} |`);
242
+ const result = buildTableComponents(table);
243
+ expect(result).toHaveLength(1);
244
+ const segment = result[0];
245
+ if (segment.type !== 'components')
246
+ throw new Error('expected components segment');
247
+ const container = segment.components[0];
248
+ if (container.type !== ComponentType.Container)
249
+ throw new Error('expected container');
250
+ const textDisplay = container.components[0];
251
+ if (textDisplay.type !== ComponentType.TextDisplay)
252
+ throw new Error('expected text display');
253
+ expect(textDisplay.content.length).toBeLessThanOrEqual(4000);
254
+ });
255
+ test('clamps a huge single row with a button so text + label stays under 4000 chars', () => {
256
+ // Regression: content used to be clamped to 4000 chars BEFORE adding the
257
+ // button label length, so a huge cell + button label could total >4000.
258
+ const header = '| Name | Description | Action |';
259
+ const sep = '| --- | --- | --- |';
260
+ const hugeValue = 'z'.repeat(5000);
261
+ const table = parseTable(`${header}\n${sep}\n| row | ${hugeValue} | <button id="delete-a" variant="secondary">Delete</button> |`);
262
+ const result = buildTableComponents(table, {
263
+ resolveButtonCustomId: ({ button }) => `action:${button.id}`,
264
+ });
265
+ expect(result).toHaveLength(1);
266
+ const segment = result[0];
267
+ if (segment.type !== 'components')
268
+ throw new Error('expected components segment');
269
+ const container = segment.components[0];
270
+ if (container.type !== ComponentType.Container)
271
+ throw new Error('expected container');
272
+ const totalText = container.components.reduce((sum, child) => {
273
+ if ('content' in child && typeof child.content === 'string') {
274
+ sum += child.content.length;
275
+ }
276
+ if ('components' in child && Array.isArray(child.components)) {
277
+ for (const nested of child.components) {
278
+ if ('label' in nested && typeof nested.label === 'string') {
279
+ sum += nested.label.length;
280
+ }
281
+ }
282
+ }
283
+ return sum;
284
+ }, 0);
285
+ expect(totalText).toBeLessThanOrEqual(4000);
286
+ });
208
287
  test('renders wide rows with buttons without using sections', () => {
209
288
  const table = parseTable(`| Thread | Name | Status | Created | Folder | Action |
210
289
  | --- | --- | --- | --- | --- | --- |
@@ -475,6 +554,48 @@ Still open`);
475
554
  ]
476
555
  `);
477
556
  });
557
+ test('clamps callout plain text content that alone exceeds 4000 chars', () => {
558
+ const hugeBody = 'a'.repeat(5000);
559
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">\n${hugeBody}\n</callout>`);
560
+ expect(result).toHaveLength(1);
561
+ const segment = result[0];
562
+ if (segment.type !== 'components')
563
+ throw new Error('expected components segment');
564
+ const container = segment.components[0];
565
+ if (container.type !== ComponentType.Container)
566
+ throw new Error('expected container');
567
+ const textDisplay = container.components[0];
568
+ if (textDisplay.type !== ComponentType.TextDisplay)
569
+ throw new Error('expected text display');
570
+ expect(textDisplay.content.length).toBeLessThanOrEqual(4000);
571
+ });
572
+ test('splits a callout with many button rows across chunks, counting nested button cost', () => {
573
+ // Regression: chunkCalloutChildrenByComponentLimit used to count
574
+ // `currentChunk.length` (1 per row) instead of the real component cost,
575
+ // so it never accounted for the ActionRow + Button nested inside each row
576
+ // (cost 3 per button row: TextDisplay + ActionRow + Button).
577
+ const header = '| Name | Action |';
578
+ const sep = '| --- | --- |';
579
+ const rows = Array.from({ length: 15 }, (_, i) => {
580
+ return `| wt-${i} | <button id="del-${i}" variant="secondary">Delete</button> |`;
581
+ }).join('\n');
582
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">\n${header}\n${sep}\n${rows}\n</callout>`, {
583
+ resolveButtonCustomId: ({ button }) => `action:${button.id}`,
584
+ });
585
+ // 15 button rows: each row costs TextDisplay(1) + ActionRow(1) + Button(1) = 3,
586
+ // plus a separator between rows. Real cost far exceeds MAX_COMPONENTS (40)
587
+ // for one Container, so this must split into multiple segments.
588
+ expect(result.length).toBeGreaterThan(1);
589
+ for (const segment of result) {
590
+ if (segment.type !== 'components')
591
+ continue;
592
+ const container = segment.components[0];
593
+ if (!container || container.type !== ComponentType.Container)
594
+ continue;
595
+ const cost = countComponentCost(container);
596
+ expect(cost).toBeLessThanOrEqual(40);
597
+ }
598
+ });
478
599
  });
479
600
  describe('truncateComponents', () => {
480
601
  // Build a worktree-like table with button rows to emulate real /worktrees output.
@@ -14,7 +14,6 @@ import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './
14
14
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
15
15
  import { handlePermissionButton } from './commands/permissions.js';
16
16
  import { handleAbortCommand } from './commands/abort.js';
17
- import { handleAddDirCommand } from './commands/add-dir.js';
18
17
  import { handleCompactCommand } from './commands/compact.js';
19
18
  import { handleShareCommand } from './commands/share.js';
20
19
  import { handleDiffCommand } from './commands/diff.js';
@@ -29,7 +28,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
29
28
  import { handleFileUploadButton, handleFileUploadModalSubmit, } from './commands/file-upload.js';
30
29
  import { handleActionButton } from './commands/action-buttons.js';
31
30
  import { handleHtmlActionButton } from './html-actions.js';
32
- import { handleQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
31
+ import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
33
32
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
34
33
  import { handleUserCommand } from './commands/user-command.js';
35
34
  import { handleVerbosityCommand, handleVerbositySelectMenu, } from './commands/verbosity.js';
@@ -42,7 +41,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
42
41
  import { handleScreenshareCommand } from './commands/screenshare.js';
43
42
  import { handleVscodeCommand } from './commands/vscode.js';
44
43
  import { handleModelVariantSelectMenu } from './commands/model.js';
45
- import { handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
44
+ import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
46
45
  import { hasKimakiAdminPermission, hasKimakiBotPermission } from './discord-utils.js';
47
46
  import { createLogger, LogPrefix } from './logger.js';
48
47
  import { notifyError } from './sentry.js';
@@ -200,9 +199,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
200
199
  case 'abort':
201
200
  await handleAbortCommand({ command: interaction, appId });
202
201
  return;
203
- case 'add-dir':
204
- await handleAddDirCommand({ command: interaction, appId });
205
- return;
206
202
  case 'compact':
207
203
  await handleCompactCommand({ command: interaction, appId });
208
204
  return;
@@ -224,6 +220,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
224
220
  case 'model':
225
221
  await handleModelCommand({ interaction, appId });
226
222
  return;
223
+ case 'model-variant':
224
+ await handleModelVariantCommand({ interaction, appId });
225
+ return;
227
226
  case 'login':
228
227
  if (!hasKimakiAdminPermission(interaction.member, interaction.guild)) {
229
228
  await interaction.reply({
@@ -240,6 +239,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
240
239
  case 'queue':
241
240
  await handleQueueCommand({ command: interaction, appId });
242
241
  return;
242
+ case 'clear-queue':
243
+ await handleClearQueueCommand({ command: interaction, appId });
244
+ return;
243
245
  case 'queue-command':
244
246
  await handleQueueCommandCommand({ command: interaction, appId });
245
247
  return;