kimaki 0.25.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 (70) 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/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. package/src/xai-auth-state.ts +293 -0
@@ -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);
@@ -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;
@@ -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;
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)
@@ -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,6 +1090,7 @@ 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 () => {
@@ -56,7 +56,7 @@ const ipcToolsPlugin = async () => {
56
56
  'The user sees a button, clicks it, and gets a file upload dialog. ' +
57
57
  'Returns the local file paths of downloaded files in the project directory. ' +
58
58
  'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
59
- 'IMPORTANT: Always call this tool last in your message, after all text parts.',
59
+ 'You MUST call kimaki_file_upload LAST, after ALL text. NEVER call it before your text.',
60
60
  args: {
61
61
  prompt: z
62
62
  .string()
@@ -115,8 +115,9 @@ const ipcToolsPlugin = async () => {
115
115
  Use this when the user can respond by clicking one of up to 3 buttons.
116
116
  Prefer a single button whenever possible.
117
117
  Default color is white (same visual style as permission deny button).
118
- If you need more than 3 options, use the question tool instead.
119
- IMPORTANT: Always call this tool last in your message, after all text parts.
118
+ If you need more than 3 options, use \`question\` instead.
119
+ You MUST call kimaki_action_buttons LAST, after ALL text.
120
+ NEVER call kimaki_action_buttons before your text.
120
121
 
121
122
  Examples:
122
123
  - buttons: [{"label":"Yes, proceed"}]
@@ -15,6 +15,8 @@ export { contextAwarenessPlugin } from './context-awareness-plugin.js';
15
15
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
16
16
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
17
17
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
18
+ export { openaiRotationPlugin } from './openai-auth-plugin.js';
19
+ export { xaiRotationPlugin } from './xai-auth-plugin.js';
18
20
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
19
21
  export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
20
22
  export { cacheDriftPlugin } from './cache-drift-plugin.js';