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
@@ -0,0 +1,56 @@
1
+ // Free Whisper transcription fallback via the kimaki.dev Cloudflare Worker.
2
+ // Only usable in gateway mode: the worker authenticates the request against
3
+ // the shared gateway_clients Postgres table using the CLI's own
4
+ // clientId:clientSecret pair (the same credentials already used for
5
+ // gateway-proxy REST/WebSocket calls), then runs
6
+ // @cf/openai/whisper-large-v3-turbo on Cloudflare's own Workers AI account.
7
+ // This means transcription costs Kimaki, never the user, so voice messages
8
+ // work out of the box for gateway-mode installs with no OpenAI/Gemini key
9
+ // configured.
10
+ //
11
+ // See website/src/server.tsx POST /api/transcribe for the server side.
12
+ // Rate limits (per-client burst + daily request cap) are enforced there,
13
+ // not here — a 429 from the worker just means this fallback is unavailable
14
+ // right now and the caller should fall back to the "add API key" dialog.
15
+ import { TranscriptionError } from './errors.js';
16
+ import { createLogger, LogPrefix } from './logger.js';
17
+ const voiceLogger = createLogger(LogPrefix.VOICE);
18
+ const KIMAKI_TRANSCRIBE_URL = process.env.KIMAKI_TRANSCRIBE_URL || 'https://kimaki.dev/api/transcribe';
19
+ export async function transcribeViaKimakiGateway({ audio, mediaType, clientId, clientSecret, }) {
20
+ const response = await fetch(KIMAKI_TRANSCRIBE_URL, {
21
+ method: 'POST',
22
+ headers: {
23
+ Authorization: `Bearer ${clientId}:${clientSecret}`,
24
+ 'Content-Type': mediaType || 'audio/ogg',
25
+ },
26
+ body: audio,
27
+ }).catch((cause) => {
28
+ return new TranscriptionError({
29
+ reason: 'Failed to reach kimaki.dev transcription endpoint',
30
+ cause,
31
+ });
32
+ });
33
+ if (response instanceof TranscriptionError)
34
+ return response;
35
+ if (!response.ok) {
36
+ const bodyText = await response.text().catch(() => '');
37
+ voiceLogger.log(`Kimaki gateway transcription failed: ${response.status} ${bodyText}`);
38
+ return new TranscriptionError({
39
+ reason: `kimaki.dev returned ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
40
+ });
41
+ }
42
+ const data = (await response.json().catch((cause) => {
43
+ return new TranscriptionError({
44
+ reason: 'Failed to parse transcription response',
45
+ cause,
46
+ });
47
+ }));
48
+ if (data instanceof TranscriptionError)
49
+ return data;
50
+ if (!data.text) {
51
+ return new TranscriptionError({
52
+ reason: 'Empty transcription response from kimaki gateway',
53
+ });
54
+ }
55
+ return data.text;
56
+ }
@@ -9,7 +9,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
9
9
  import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
10
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
11
11
  import { createLogger, LogPrefix } from '../logger.js';
12
- import { getCurrentModelInfo } from './model.js';
12
+ import { formatModelSource, getCurrentModelInfo, } from './model.js';
13
13
  import { isGitRepositoryRoot } from '../worktrees.js';
14
14
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
15
15
  import { WORKTREE_PREFIX } from './merge-worktree.js';
@@ -100,7 +100,7 @@ async function resolveQuickAgentNameFromInteraction({ command, }) {
100
100
  * Resolve the context for an agent command (directory, channel, session).
101
101
  * Returns null if the command cannot be executed in this context.
102
102
  */
103
- export async function resolveAgentCommandContext({ interaction, }) {
103
+ export async function resolveAgentCommandContext({ interaction, appId, }) {
104
104
  const channel = interaction.channel;
105
105
  if (!channel) {
106
106
  await interaction.editReply({
@@ -113,39 +113,35 @@ export async function resolveAgentCommandContext({ interaction, }) {
113
113
  ChannelType.PrivateThread,
114
114
  ChannelType.AnnouncementThread,
115
115
  ].includes(channel.type);
116
- let projectDirectory;
117
- let targetChannelId;
118
- let sessionId;
119
- if (isThread) {
120
- const thread = channel;
121
- const textChannel = await resolveTextChannel(thread);
122
- const metadata = await getKimakiMetadata(textChannel);
123
- projectDirectory = metadata.projectDirectory;
124
- targetChannelId = textChannel?.id || channel.id;
125
- sessionId = await getThreadSession(thread.id);
126
- }
127
- else if (channel.type === ChannelType.GuildText) {
128
- const metadata = await getKimakiMetadata(channel);
129
- projectDirectory = metadata.projectDirectory;
130
- targetChannelId = channel.id;
131
- }
132
- else {
116
+ if (!isThread &&
117
+ channel.type !== ChannelType.GuildText) {
133
118
  await interaction.editReply({
134
119
  content: 'This command can only be used in text channels or threads',
135
120
  });
136
121
  return null;
137
122
  }
138
- if (!projectDirectory) {
123
+ const resolved = await resolveWorkingDirectory({
124
+ channel: channel,
125
+ });
126
+ if (!resolved) {
139
127
  await interaction.editReply({
140
128
  content: 'This channel is not configured with a project directory',
141
129
  });
142
130
  return null;
143
131
  }
132
+ const textChannel = isThread
133
+ ? await resolveTextChannel(channel)
134
+ : undefined;
135
+ const sessionId = isThread
136
+ ? await getThreadSession(channel.id)
137
+ : undefined;
144
138
  return {
145
- dir: projectDirectory,
146
- channelId: targetChannelId,
139
+ dir: resolved.projectDirectory,
140
+ workingDirectory: resolved.workingDirectory,
141
+ channelId: textChannel?.id || channel.id,
147
142
  sessionId,
148
143
  isThread,
144
+ appId,
149
145
  };
150
146
  }
151
147
  /**
@@ -165,6 +161,42 @@ export async function setAgentForContext({ context, agentName, }) {
165
161
  agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
166
162
  }
167
163
  }
164
+ function formatAgentModelLine(modelInfo) {
165
+ if (modelInfo.type === 'none')
166
+ return '';
167
+ const source = formatModelSource({
168
+ type: modelInfo.type,
169
+ agentName: modelInfo.type === 'agent' ? modelInfo.agentName : undefined,
170
+ });
171
+ const line = `\nModel: *${modelInfo.model}* (${source})`;
172
+ if (modelInfo.type !== 'session' && modelInfo.type !== 'channel') {
173
+ return line;
174
+ }
175
+ return `${line}\nThis model comes from a ${modelInfo.type} override. Use /model and press Clear override if you want this agent's model.`;
176
+ }
177
+ function formatAgentPreferenceReply({ agentName, previousAgentName, modelInfo, scope, }) {
178
+ const sameAgent = previousAgentName === agentName;
179
+ const verb = sameAgent ? 'Using' : 'Switched to';
180
+ const previousText = !sameAgent && previousAgentName ? ` (was **${previousAgentName}**)` : '';
181
+ const modelText = formatAgentModelLine(modelInfo);
182
+ if (scope === 'session') {
183
+ return `${verb} **${agentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`;
184
+ }
185
+ return `${verb} **${agentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`;
186
+ }
187
+ async function resolveAgentModelInfo({ context, agentName, }) {
188
+ const getClient = await initializeOpencodeForDirectory(context.dir);
189
+ if (getClient instanceof Error)
190
+ return { type: 'none' };
191
+ return getCurrentModelInfo({
192
+ sessionId: context.sessionId,
193
+ channelId: context.channelId,
194
+ appId: context.appId,
195
+ agentPreference: agentName,
196
+ getClient,
197
+ directory: context.workingDirectory,
198
+ });
199
+ }
168
200
  export async function handleAgentCommand({ interaction, appId, }) {
169
201
  await interaction.deferReply();
170
202
  const context = await resolveAgentCommandContext({ interaction, appId });
@@ -178,7 +210,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
178
210
  return;
179
211
  }
180
212
  const agentsResponse = await getClient().app.agents({
181
- directory: context.dir,
213
+ directory: context.workingDirectory,
182
214
  });
183
215
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
184
216
  await interaction.editReply({ content: 'No agents available' });
@@ -259,19 +291,26 @@ export async function handleAgentSelectMenu(interaction) {
259
291
  return;
260
292
  }
261
293
  try {
294
+ const previousAgent = await getCurrentAgentInfo({
295
+ sessionId: context.sessionId,
296
+ channelId: context.channelId,
297
+ });
298
+ const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
262
299
  await setAgentForContext({ context, agentName: selectedAgent });
263
- if (context.isThread && context.sessionId) {
264
- await interaction.editReply({
265
- content: `Agent preference set for this session: **${selectedAgent}**\nThe agent will change on the next message.`,
266
- components: [],
267
- });
268
- }
269
- else {
270
- await interaction.editReply({
271
- content: `Agent preference set for this channel: **${selectedAgent}**\nAll new sessions in this channel will use this agent.`,
272
- components: [],
273
- });
274
- }
300
+ const modelInfo = await resolveAgentModelInfo({
301
+ context,
302
+ agentName: selectedAgent,
303
+ });
304
+ const scope = context.isThread && context.sessionId ? 'session' : 'channel';
305
+ await interaction.editReply({
306
+ content: formatAgentPreferenceReply({
307
+ agentName: selectedAgent,
308
+ previousAgentName,
309
+ modelInfo,
310
+ scope,
311
+ }),
312
+ components: [],
313
+ });
275
314
  pendingAgentContexts.delete(contextHash);
276
315
  }
277
316
  catch (error) {
@@ -317,46 +356,20 @@ export async function handleQuickAgentCommand({ command, appId, }) {
317
356
  channelId: context.channelId,
318
357
  });
319
358
  const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
320
- if (previousAgentName === resolvedAgentName) {
321
- await command.editReply({
322
- content: `Already using **${resolvedAgentName}** agent`,
323
- });
324
- return;
325
- }
326
- // Set the agent preference in DB for this context.
327
359
  await setAgentForContext({ context, agentName: resolvedAgentName });
328
- const previousText = previousAgentName
329
- ? ` (was **${previousAgentName}**)`
330
- : '';
331
- // Resolve the model that will now be used for the new agent so we can
332
- // show it in the reply. setAgentForContext already cleared any session
333
- // model preference, so getCurrentModelInfo falls through to the agent's
334
- // configured model (or channel/global/default).
335
- const modelInfo = await (async () => {
336
- const getClient = await initializeOpencodeForDirectory(context.dir);
337
- if (getClient instanceof Error) {
338
- return { type: 'none' };
339
- }
340
- return getCurrentModelInfo({
341
- sessionId: context.sessionId,
342
- channelId: context.channelId,
343
- appId,
344
- agentPreference: resolvedAgentName,
345
- getClient,
346
- directory: context.dir,
347
- });
348
- })();
349
- const modelText = modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`;
350
- if (context.isThread && context.sessionId) {
351
- await command.editReply({
352
- content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
353
- });
354
- }
355
- else {
356
- await command.editReply({
357
- content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
358
- });
359
- }
360
+ const modelInfo = await resolveAgentModelInfo({
361
+ context,
362
+ agentName: resolvedAgentName,
363
+ });
364
+ const scope = context.isThread && context.sessionId ? 'session' : 'channel';
365
+ await command.editReply({
366
+ content: formatAgentPreferenceReply({
367
+ agentName: resolvedAgentName,
368
+ previousAgentName,
369
+ modelInfo,
370
+ scope,
371
+ }),
372
+ });
360
373
  }
361
374
  catch (error) {
362
375
  agentLogger.error('Error in quick agent command:', error);
@@ -6,7 +6,7 @@
6
6
  // Cross-menu state: Discord doesn't expose already-selected values on sibling
7
7
  // select menus in the same message. We track partial selections in the context
8
8
  // Map. Whichever menu fires second sees the first selection stored and applies.
9
- import { StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
9
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
10
10
  import crypto from 'node:crypto';
11
11
  import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
12
12
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -39,6 +39,22 @@ function formatSourceLabel(info) {
39
39
  return 'none';
40
40
  }
41
41
  }
42
+ /** /model-variant slash command entrypoint — defers reply then delegates to the shared picker. */
43
+ export async function handleModelVariantCommand({ interaction, appId, }) {
44
+ await interaction.deferReply();
45
+ const channel = interaction.channel;
46
+ if (!channel) {
47
+ await interaction.editReply({
48
+ content: 'This command can only be used in a channel',
49
+ });
50
+ return;
51
+ }
52
+ await showModelVariantPicker({
53
+ channel,
54
+ appId,
55
+ editReply: (options) => interaction.editReply(options),
56
+ });
57
+ }
42
58
  export async function showModelVariantPicker({ channel, appId, editReply, }) {
43
59
  const isThread = [
44
60
  ChannelType.PublicThread,
@@ -37,6 +37,24 @@ function setModelContext(contextHash, context) {
37
37
  pendingModelContexts.delete(contextHash);
38
38
  }, MODEL_CONTEXT_TTL_MS).unref();
39
39
  }
40
+ export function formatModelSource({ type, agentName, }) {
41
+ switch (type) {
42
+ case 'session':
43
+ return 'session override';
44
+ case 'agent':
45
+ return `agent "${agentName}"`;
46
+ case 'channel':
47
+ return 'channel override';
48
+ case 'global':
49
+ return 'global default';
50
+ case 'opencode-config':
51
+ case 'opencode-recent':
52
+ case 'opencode-provider-default':
53
+ return 'opencode default';
54
+ default:
55
+ return 'none';
56
+ }
57
+ }
40
58
  function parseModelId(modelString) {
41
59
  const [providerID, ...modelParts] = modelString.split('/');
42
60
  const modelID = modelParts.join('/');
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * CLI commands for multi-provider OAuth account management.
3
3
  * Mounted via goke .use() in cli.ts under the `multioauth` namespace.
4
- * Manages Anthropic and OpenAI OAuth account rotation pools.
4
+ * Manages Anthropic, OpenAI, and xAI OAuth account rotation pools.
5
5
  */
6
6
  import { goke } from 'goke';
7
7
  import { accountLabel as anthropicAccountLabel, accountsFilePath as anthropicAccountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore as loadAnthropicAccountStore, removeAccount as removeAnthropicAccount, } from '../anthropic-auth-state.js';
8
8
  import { accountLabel as openaiAccountLabel, openaiAccountsFilePath, getCurrentOpenAIAccount, loadOpenAIAccountStore, removeOpenAIAccount, saveOpenAIAccountStore, } from '../openai-auth-state.js';
9
+ import { accountLabel as xaiAccountLabel, xaiAccountsFilePath, getCurrentXAIAccount, loadXAIAccountStore, removeXAIAccount, saveXAIAccountStore, } from '../xai-auth-state.js';
9
10
  const EXIT_NO_RESTART = 64;
10
11
  function resolveAccountIndex(indexOrEmail, accounts) {
11
12
  const value = Number(indexOrEmail);
@@ -25,6 +26,7 @@ multioauth
25
26
  .action(async () => {
26
27
  const anthropicStore = await loadAnthropicAccountStore();
27
28
  const openaiStore = await loadOpenAIAccountStore();
29
+ const xaiStore = await loadXAIAccountStore();
28
30
  console.log('Anthropic OAuth accounts:');
29
31
  if (anthropicStore.accounts.length === 0) {
30
32
  console.log(' (none)');
@@ -46,6 +48,17 @@ multioauth
46
48
  console.log(` ${active} ${index + 1}. ${openaiAccountLabel(account)}`);
47
49
  });
48
50
  }
51
+ console.log('');
52
+ console.log('xAI OAuth accounts:');
53
+ if (xaiStore.accounts.length === 0) {
54
+ console.log(' (none)');
55
+ }
56
+ else {
57
+ xaiStore.accounts.forEach((account, index) => {
58
+ const active = index === xaiStore.activeIndex ? '*' : ' ';
59
+ console.log(` ${active} ${index + 1}. ${xaiAccountLabel(account)}`);
60
+ });
61
+ }
49
62
  process.exit(0);
50
63
  });
51
64
  // --- Anthropic subcommands ---
@@ -198,6 +211,9 @@ multioauth
198
211
  const json = (await response.json());
199
212
  accessToken = json.access_token;
200
213
  account.access = accessToken;
214
+ // OpenAI can rotate refresh tokens; store the new one if returned
215
+ if (json.refresh_token)
216
+ account.refresh = json.refresh_token;
201
217
  account.expires = Date.now() + (json.expires_in ?? 3600) * 1000;
202
218
  }
203
219
  catch {
@@ -253,4 +269,156 @@ multioauth
253
269
  await saveOpenAIAccountStore(store);
254
270
  process.exit(0);
255
271
  });
272
+ // --- xAI subcommands ---
273
+ multioauth
274
+ .command('multioauth xai list', 'List stored xAI OAuth accounts used for automatic rotation')
275
+ .action(async () => {
276
+ const store = await loadXAIAccountStore();
277
+ console.log(`Store: ${xaiAccountsFilePath()}`);
278
+ if (store.accounts.length === 0) {
279
+ console.log('No xAI OAuth accounts configured.');
280
+ process.exit(0);
281
+ }
282
+ store.accounts.forEach((account, index) => {
283
+ const active = index === store.activeIndex ? '*' : ' ';
284
+ console.log(`${active} ${index + 1}. ${xaiAccountLabel(account)}`);
285
+ });
286
+ process.exit(0);
287
+ });
288
+ multioauth
289
+ .command('multioauth xai current', 'Show the current xAI OAuth account being used')
290
+ .action(async () => {
291
+ const current = await getCurrentXAIAccount();
292
+ console.log(`Store: ${xaiAccountsFilePath()}`);
293
+ console.log(`Auth: ${authFilePath()}`);
294
+ if (!current) {
295
+ console.log('No active xAI OAuth account configured.');
296
+ process.exit(0);
297
+ }
298
+ const lines = [];
299
+ lines.push(`Current: ${xaiAccountLabel(current.account || current.auth, current.index)}`);
300
+ if (current.account?.email) {
301
+ lines.push(`Email: ${current.account.email}`);
302
+ }
303
+ else {
304
+ lines.push('Email: unavailable');
305
+ }
306
+ if (current.account?.accountId) {
307
+ lines.push(`Account ID: ${current.account.accountId}`);
308
+ }
309
+ if (!current.account) {
310
+ lines.push('Rotation pool entry: not found');
311
+ }
312
+ console.log(lines.join('\n'));
313
+ process.exit(0);
314
+ });
315
+ multioauth
316
+ .command('multioauth xai remove <indexOrEmail>', 'Remove an xAI OAuth account from the rotation pool')
317
+ .action(async (indexOrEmail) => {
318
+ const store = await loadXAIAccountStore();
319
+ const resolvedIndex = resolveAccountIndex(indexOrEmail, store.accounts);
320
+ if (resolvedIndex < 0) {
321
+ console.error('Usage: kimaki multioauth xai remove <index-or-email>');
322
+ process.exit(EXIT_NO_RESTART);
323
+ }
324
+ const removed = store.accounts[resolvedIndex];
325
+ await removeXAIAccount(resolvedIndex);
326
+ console.log(`Removed xAI account ${removed ? xaiAccountLabel(removed, resolvedIndex) : indexOrEmail}`);
327
+ process.exit(0);
328
+ });
329
+ multioauth
330
+ .command('multioauth xai check', 'Test all xAI OAuth accounts for usage limits')
331
+ .action(async () => {
332
+ const store = await loadXAIAccountStore();
333
+ if (store.accounts.length === 0) {
334
+ console.log('No xAI OAuth accounts configured.');
335
+ process.exit(0);
336
+ }
337
+ console.log('Checking usage limits for all xAI accounts...\n');
338
+ for (let i = 0; i < store.accounts.length; i++) {
339
+ const account = store.accounts[i];
340
+ if (!account)
341
+ continue;
342
+ const marker = i === store.activeIndex ? '*' : ' ';
343
+ const label = account.email ?? account.accountId ?? 'unknown';
344
+ process.stdout.write(`${marker} ${i + 1}. ${label}: `);
345
+ // Refresh token if expired
346
+ let accessToken = account.access;
347
+ if (account.expires < Date.now()) {
348
+ try {
349
+ const response = await fetch('https://auth.x.ai/oauth2/token', {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
352
+ body: new URLSearchParams({
353
+ grant_type: 'refresh_token',
354
+ refresh_token: account.refresh,
355
+ client_id: 'b1a00492-073a-47ea-816f-4c329264a828',
356
+ }).toString(),
357
+ });
358
+ if (!response.ok) {
359
+ console.log('ERROR - Token expired, refresh failed');
360
+ continue;
361
+ }
362
+ const json = (await response.json());
363
+ accessToken = json.access_token;
364
+ account.access = accessToken;
365
+ // xAI can rotate refresh tokens; store the new one if returned
366
+ if (json.refresh_token)
367
+ account.refresh = json.refresh_token;
368
+ account.expires = Date.now() + (json.expires_in ?? 3600) * 1000;
369
+ }
370
+ catch {
371
+ console.log('ERROR - Token expired, refresh failed');
372
+ continue;
373
+ }
374
+ }
375
+ try {
376
+ const response = await fetch('https://api.x.ai/v1/chat/completions', {
377
+ method: 'POST',
378
+ headers: {
379
+ 'Content-Type': 'application/json',
380
+ Authorization: `Bearer ${accessToken}`,
381
+ },
382
+ body: JSON.stringify({
383
+ model: 'grok-3-mini',
384
+ max_tokens: 1,
385
+ messages: [{ role: 'user', content: 'hi' }],
386
+ }),
387
+ });
388
+ if (response.ok) {
389
+ console.log('OK');
390
+ continue;
391
+ }
392
+ const text = await response.text();
393
+ const isLimited = text.includes('usage limit') ||
394
+ text.includes('rate limit') ||
395
+ text.includes('usage_limit') ||
396
+ text.includes('balance exhausted') ||
397
+ response.status === 429 ||
398
+ response.status === 402;
399
+ if (isLimited) {
400
+ let resetInfo;
401
+ try {
402
+ const json = JSON.parse(text);
403
+ const match = json.error?.message?.match(/try again (after|in) ([^.]+)/i);
404
+ if (match)
405
+ resetInfo = match[2];
406
+ }
407
+ catch { }
408
+ console.log(`LIMITED${resetInfo ? ` (resets ${resetInfo})` : ''}`);
409
+ }
410
+ else if (response.status === 401) {
411
+ console.log('ERROR - Auth failed (token invalid)');
412
+ }
413
+ else {
414
+ console.log(`ERROR - ${response.status}: ${text.slice(0, 100)}`);
415
+ }
416
+ }
417
+ catch (err) {
418
+ console.log(`ERROR - ${err instanceof Error ? err.message : String(err)}`);
419
+ }
420
+ }
421
+ await saveXAIAccountStore(store);
422
+ process.exit(0);
423
+ });
256
424
  export default multioauth;
@@ -114,6 +114,70 @@ export async function handleQueueCommand({ command, appId, }) {
114
114
  flags: SILENT_MESSAGE_FLAGS,
115
115
  });
116
116
  }
117
+ export async function handleClearQueueCommand({ command, }) {
118
+ const channel = command.channel;
119
+ const position = command.options.getInteger('position') ?? undefined;
120
+ if (!channel) {
121
+ await command.reply({
122
+ content: 'This command can only be used in a channel',
123
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
124
+ });
125
+ return;
126
+ }
127
+ const isThread = [
128
+ ChannelType.PublicThread,
129
+ ChannelType.PrivateThread,
130
+ ChannelType.AnnouncementThread,
131
+ ].includes(channel.type);
132
+ if (!isThread) {
133
+ await command.reply({
134
+ content: 'This command can only be used in a thread',
135
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
136
+ });
137
+ return;
138
+ }
139
+ const runtime = getRuntime(channel.id);
140
+ const queueLength = runtime?.getQueueLength() ?? 0;
141
+ if (queueLength === 0) {
142
+ await command.reply({
143
+ content: 'No messages in queue',
144
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
145
+ });
146
+ return;
147
+ }
148
+ if (position !== undefined) {
149
+ const removed = runtime?.removeQueuePosition(position);
150
+ if (!removed) {
151
+ await command.reply({
152
+ content: `No queued message at position ${position}`,
153
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
154
+ });
155
+ return;
156
+ }
157
+ await command.reply({
158
+ content: `Cleared queued message at position ${position}`,
159
+ flags: SILENT_MESSAGE_FLAGS,
160
+ });
161
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
162
+ return;
163
+ }
164
+ const cleared = runtime?.clearQueue() ?? [];
165
+ const lines = cleared.map((item, i) => {
166
+ const label = item.command
167
+ ? `/${item.command.name}`
168
+ : item.prompt;
169
+ return `${i + 1}. ${label}`;
170
+ });
171
+ let list = lines.join('\n');
172
+ if (list.length > 600) {
173
+ list = list.slice(0, 597) + '...';
174
+ }
175
+ await command.reply({
176
+ content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
177
+ flags: SILENT_MESSAGE_FLAGS,
178
+ });
179
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
180
+ }
117
181
  export async function handleQueueCommandCommand({ command, appId, }) {
118
182
  const commandName = command.options.getString('command', true);
119
183
  const args = command.options.getString('arguments') || '';
@@ -222,6 +222,12 @@ async function handleRunTaskAction({ interaction, taskId, showAll, }) {
222
222
  if (result.kind === 'skipped') {
223
223
  return `Task #${taskId} is already running or was claimed elsewhere.`;
224
224
  }
225
+ if (result.kind === 'condition-not-met') {
226
+ return `Task #${taskId} pre-run condition did not pass.`;
227
+ }
228
+ if (result.kind === 'concurrency-blocked') {
229
+ return `Task #${taskId} still has an active session.`;
230
+ }
225
231
  if (result.kind === 'failed') {
226
232
  return `Task #${taskId} failed: ${result.error.message}`;
227
233
  }
@@ -8,27 +8,9 @@ import * as schema from '../schema.js';
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js';
9
9
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
10
10
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
11
- import { getCurrentModelInfo } from './model.js';
11
+ import { formatModelSource, getCurrentModelInfo } from './model.js';
12
12
  import { createLogger, LogPrefix } from '../logger.js';
13
13
  const unsetModelLogger = createLogger(LogPrefix.MODEL);
14
- function formatModelSource(type, agentName) {
15
- switch (type) {
16
- case 'session':
17
- return 'session override';
18
- case 'agent':
19
- return `agent "${agentName}"`;
20
- case 'channel':
21
- return 'channel override';
22
- case 'global':
23
- return 'global default';
24
- case 'opencode-config':
25
- case 'opencode-recent':
26
- case 'opencode-provider-default':
27
- return 'opencode default';
28
- default:
29
- return 'none';
30
- }
31
- }
32
14
  /**
33
15
  * Clear the nearest model override for the current channel/thread.
34
16
  * In thread: clears session override if exists, otherwise channel override.
@@ -110,7 +92,10 @@ export async function clearModelOverride({ channel, appId, editReply, }) {
110
92
  newModelText =
111
93
  newModelInfo.type === 'none'
112
94
  ? 'none'
113
- : `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, agentName)})`;
95
+ : `\`${newModelInfo.model}\` (${formatModelSource({
96
+ type: newModelInfo.type,
97
+ agentName,
98
+ })})`;
114
99
  }
115
100
  // Check if there's a running request and abort+retry with new model (only for session changes in threads)
116
101
  let retried = false;