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
@@ -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';
@@ -1,10 +1,13 @@
1
1
  // OpenCode message part formatting for Discord.
2
2
  // Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
3
3
  // handles file attachments, and provides tool summary generation.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
4
6
  import { createLogger, LogPrefix } from './logger.js';
5
- import { FetchError } from './errors.js';
7
+ import { FetchError, FilesystemOperationError } from './errors.js';
6
8
  import { processImage } from './image-utils.js';
7
9
  import { parsePatchFileCounts } from './patch-text-parser.js';
10
+ import { getDataDir } from './config.js';
8
11
  const logger = createLogger(LogPrefix.FORMATTING);
9
12
  /**
10
13
  * Serialize Discord embeds into plain text so the AI model can read them.
@@ -210,22 +213,77 @@ export function isTextMimeType(contentType) {
210
213
  }
211
214
  return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix));
212
215
  }
216
+ // Small Discord "send as file" prompts stay inlined. Bigger dumps only get a URL.
217
+ export const TEXT_ATTACHMENT_INLINE_LIMIT_BYTES = 64 * 1024;
218
+ const KIMAKI_SEND_PROMPT_ATTACHMENT_NAME = 'prompt.md';
219
+ function formatAttachmentSize(bytes) {
220
+ if (bytes < 1024)
221
+ return `${bytes} B`;
222
+ if (bytes < 1024 * 1024) {
223
+ const kb = bytes / 1024;
224
+ return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`;
225
+ }
226
+ const mb = bytes / (1024 * 1024);
227
+ return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
228
+ }
229
+ function shouldInlineTextAttachment(attachment) {
230
+ if (attachment.name === KIMAKI_SEND_PROMPT_ATTACHMENT_NAME)
231
+ return true;
232
+ return attachment.size <= TEXT_ATTACHMENT_INLINE_LIMIT_BYTES;
233
+ }
234
+ function textAttachmentAttrs(attachment, extra = []) {
235
+ return [
236
+ `filename="${attachment.name}"`,
237
+ `mime="${attachment.contentType}"`,
238
+ `size="${attachment.size}"`,
239
+ `url="${attachment.url}"`,
240
+ ...extra,
241
+ ].join(' ');
242
+ }
243
+ function safeAttachmentBasename(name) {
244
+ const sanitized = path.basename(name).replace(/\0/g, '');
245
+ if (!sanitized || sanitized === '.' || sanitized === '..')
246
+ return 'attachment';
247
+ return sanitized;
248
+ }
249
+ function localAttachmentPath(attachment) {
250
+ const id = attachment.id || attachment.url.slice(-12).replace(/[^a-zA-Z0-9]/g, '');
251
+ return path.join(getDataDir(), 'attachments', `${id}-${safeAttachmentBasename(attachment.name)}`);
252
+ }
213
253
  export async function getTextAttachments(message) {
214
254
  const textAttachments = Array.from(message.attachments.values()).filter((attachment) => isTextMimeType(attachment.contentType));
215
255
  if (textAttachments.length === 0) {
216
256
  return '';
217
257
  }
258
+ const attachmentsDir = path.join(getDataDir(), 'attachments');
259
+ fs.mkdirSync(attachmentsDir, { recursive: true });
218
260
  const textContents = await Promise.all(textAttachments.map(async (attachment) => {
219
261
  const response = await fetch(attachment.url)
220
262
  .catch((e) => new FetchError({ url: attachment.url, cause: e }));
221
263
  if (response instanceof Error) {
222
- return `<attachment filename="${attachment.name}" error="${response.message}" />`;
264
+ return `<attachment ${textAttachmentAttrs(attachment)} error="${response.message}" />`;
223
265
  }
224
266
  if (!response.ok) {
225
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
267
+ return `<attachment ${textAttachmentAttrs(attachment)} error="Failed to fetch: ${response.status}" />`;
268
+ }
269
+ const buffer = Buffer.from(await response.arrayBuffer());
270
+ const savedPath = localAttachmentPath(attachment);
271
+ const written = await fs.promises
272
+ .writeFile(savedPath, buffer)
273
+ .catch((e) => new FilesystemOperationError({ operation: 'write attachment', cause: e }));
274
+ if (written instanceof Error) {
275
+ logger.error(`Failed to save attachment ${attachment.name}:`, written.message);
226
276
  }
227
- const text = await response.text();
228
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
277
+ const pathAttr = written instanceof Error ? [] : [`path="${savedPath}"`];
278
+ if (!shouldInlineTextAttachment(attachment)) {
279
+ const sizeLabel = formatAttachmentSize(attachment.size);
280
+ return [
281
+ `<attachment ${textAttachmentAttrs(attachment, [...pathAttr, 'large="true"'])}>`,
282
+ `This file is large (${sizeLabel}, ${attachment.contentType || 'unknown type'}). Contents were not inlined to save context. Read the local path.`,
283
+ `</attachment>`,
284
+ ].join('\n');
285
+ }
286
+ return `<attachment ${textAttachmentAttrs(attachment, pathAttr)}>\n${buffer.toString('utf8')}\n</attachment>`;
229
287
  }));
230
288
  return textContents.join('\n\n');
231
289
  }
@@ -419,6 +477,27 @@ export function formatTodoList(part) {
419
477
  const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
420
478
  return `${num} **${escapeInlineMarkdown(content)}**`;
421
479
  }
480
+ export function formatTaskToolTitle(part) {
481
+ if (part.tool !== 'task' || part.state.status === 'pending')
482
+ return '';
483
+ const childSessionId = part.state.metadata?.sessionId;
484
+ if (typeof childSessionId !== 'string' || !childSessionId)
485
+ return '';
486
+ const description = part.state.input?.description;
487
+ const stateTitle = part.state.status === 'running' || part.state.status === 'completed'
488
+ ? part.state.title
489
+ : undefined;
490
+ const title = typeof description === 'string' && description
491
+ ? description
492
+ : typeof stateTitle === 'string'
493
+ ? stateTitle
494
+ : '';
495
+ if (!title)
496
+ return '';
497
+ const subagentType = part.state.input?.subagent_type;
498
+ const agent = typeof subagentType === 'string' ? subagentType : 'task';
499
+ return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`;
500
+ }
422
501
  export function formatPart(part, prefix) {
423
502
  const pfx = prefix ? `${prefix} ⋅ ` : '';
424
503
  if (part.type === 'text') {
@@ -1,5 +1,8 @@
1
- import { describe, test, expect } from 'vitest';
2
- import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { afterEach, describe, test, expect } from 'vitest';
4
+ import { formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, serializeEmbeds, serializePoll, serializeMessageSnapshots, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
5
+ import { getDataDir } from './config.js';
3
6
  describe('formatPart', () => {
4
7
  test('callout text does not get ⬥ prefix', () => {
5
8
  const part = {
@@ -42,6 +45,27 @@ describe('formatPart', () => {
42
45
  `);
43
46
  });
44
47
  });
48
+ describe('formatTaskToolTitle', () => {
49
+ test('uses the completed task title when OpenAI omits the description', () => {
50
+ const part = {
51
+ id: 'prt_task',
52
+ type: 'tool',
53
+ tool: 'task',
54
+ callID: 'call_task',
55
+ sessionID: 'ses_parent',
56
+ messageID: 'msg_assistant',
57
+ state: {
58
+ status: 'completed',
59
+ input: { subagent_type: 'general' },
60
+ output: '',
61
+ title: 'Classify pending changes',
62
+ metadata: { sessionId: 'ses_child' },
63
+ time: { start: 1, end: 2 },
64
+ },
65
+ };
66
+ expect(formatTaskToolTitle(part)).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
67
+ });
68
+ });
45
69
  describe('formatBashToolTitle', () => {
46
70
  test('short single-line command shown in full', () => {
47
71
  expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
@@ -356,3 +380,137 @@ describe('serializeMessageSnapshots', () => {
356
380
  `);
357
381
  });
358
382
  });
383
+ describe('getTextAttachments', () => {
384
+ const originalFetch = globalThis.fetch;
385
+ afterEach(() => {
386
+ globalThis.fetch = originalFetch;
387
+ });
388
+ function stubFetch(impl) {
389
+ const fetchFn = async (input) => impl(String(input));
390
+ fetchFn.preconnect = () => { };
391
+ globalThis.fetch = fetchFn;
392
+ }
393
+ function messageWithAttachments(attachments) {
394
+ return {
395
+ attachments: new Map(attachments.map((attachment) => {
396
+ return [attachment.id, attachment];
397
+ })),
398
+ };
399
+ }
400
+ function snapshotAttachments(result) {
401
+ return result.replaceAll(getDataDir(), '<dataDir>');
402
+ }
403
+ test('inlines small text files and saves them locally', async () => {
404
+ stubFetch(async () => {
405
+ return new Response('hello from file', { status: 200 });
406
+ });
407
+ const result = await getTextAttachments(messageWithAttachments([
408
+ {
409
+ id: 'att1',
410
+ name: 'notes.txt',
411
+ contentType: 'text/plain',
412
+ url: 'https://cdn.example/notes.txt',
413
+ size: 16,
414
+ },
415
+ ]));
416
+ const savedPath = path.join(getDataDir(), 'attachments', 'att1-notes.txt');
417
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('hello from file');
418
+ expect(result).toContain('https://cdn.example/notes.txt');
419
+ expect(result).toContain(savedPath);
420
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
421
+ "<attachment filename="notes.txt" mime="text/plain" size="16" url="https://cdn.example/notes.txt" path="<dataDir>/attachments/att1-notes.txt">
422
+ hello from file
423
+ </attachment>"
424
+ `);
425
+ });
426
+ test('saves large text files locally without inlining contents', async () => {
427
+ stubFetch(async () => {
428
+ return new Response('SHOULD NOT BE INLINED', { status: 200 });
429
+ });
430
+ const result = await getTextAttachments(messageWithAttachments([
431
+ {
432
+ id: 'att2',
433
+ name: 'huge.log',
434
+ contentType: 'text/plain',
435
+ url: 'https://cdn.discordapp.com/attachments/1/2/huge.log',
436
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
437
+ },
438
+ ]));
439
+ const savedPath = path.join(getDataDir(), 'attachments', 'att2-huge.log');
440
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('SHOULD NOT BE INLINED');
441
+ expect(result).not.toContain('SHOULD NOT BE INLINED');
442
+ expect(result).toContain('https://cdn.discordapp.com/attachments/1/2/huge.log');
443
+ expect(result).toContain(savedPath);
444
+ expect(result).toContain('text/plain');
445
+ expect(result).toContain(String(TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1));
446
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
447
+ "<attachment filename="huge.log" mime="text/plain" size="65537" url="https://cdn.discordapp.com/attachments/1/2/huge.log" path="<dataDir>/attachments/att2-huge.log" large="true">
448
+ This file is large (64 KB, text/plain). Contents were not inlined to save context. Read the local path.
449
+ </attachment>"
450
+ `);
451
+ });
452
+ test('still inlines large prompt.md attachments from kimaki send', async () => {
453
+ stubFetch(async () => {
454
+ return new Response('the actual long prompt', { status: 200 });
455
+ });
456
+ const result = await getTextAttachments(messageWithAttachments([
457
+ {
458
+ id: 'att3',
459
+ name: 'prompt.md',
460
+ contentType: 'text/markdown',
461
+ url: 'https://cdn.example/prompt.md',
462
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
463
+ },
464
+ ]));
465
+ const savedPath = path.join(getDataDir(), 'attachments', 'att3-prompt.md');
466
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('the actual long prompt');
467
+ expect(result).toContain('the actual long prompt');
468
+ expect(result).toContain('https://cdn.example/prompt.md');
469
+ expect(result).toContain(savedPath);
470
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
471
+ "<attachment filename="prompt.md" mime="text/markdown" size="65537" url="https://cdn.example/prompt.md" path="<dataDir>/attachments/att3-prompt.md">
472
+ the actual long prompt
473
+ </attachment>"
474
+ `);
475
+ });
476
+ test('keeps small files inlined next to large file references', async () => {
477
+ stubFetch(async (url) => {
478
+ if (url.includes('small.txt')) {
479
+ return new Response('tiny', { status: 200 });
480
+ }
481
+ return new Response('SHOULD NOT BE INLINED', { status: 200 });
482
+ });
483
+ const result = await getTextAttachments(messageWithAttachments([
484
+ {
485
+ id: 'att4',
486
+ name: 'small.txt',
487
+ contentType: 'text/plain',
488
+ url: 'https://cdn.example/small.txt',
489
+ size: 4,
490
+ },
491
+ {
492
+ id: 'att5',
493
+ name: 'dump.json',
494
+ contentType: 'application/json',
495
+ url: 'https://cdn.example/dump.json',
496
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 50,
497
+ },
498
+ ]));
499
+ expect(result).toContain('tiny');
500
+ expect(result).not.toContain('SHOULD NOT BE INLINED');
501
+ expect(result).toContain('https://cdn.example/small.txt');
502
+ expect(result).toContain('https://cdn.example/dump.json');
503
+ expect(result).toContain(path.join(getDataDir(), 'attachments', 'att4-small.txt'));
504
+ expect(result).toContain(path.join(getDataDir(), 'attachments', 'att5-dump.json'));
505
+ expect(result).toContain('application/json');
506
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
507
+ "<attachment filename="small.txt" mime="text/plain" size="4" url="https://cdn.example/small.txt" path="<dataDir>/attachments/att4-small.txt">
508
+ tiny
509
+ </attachment>
510
+
511
+ <attachment filename="dump.json" mime="application/json" size="65586" url="https://cdn.example/dump.json" path="<dataDir>/attachments/att5-dump.json" large="true">
512
+ This file is large (64 KB, application/json). Contents were not inlined to save context. Read the local path.
513
+ </attachment>"
514
+ `);
515
+ });
516
+ });
@@ -10,9 +10,9 @@ import { DiscordOperationError, OpenCodeSdkError } from './errors.js';
10
10
  import { resolveMentions, getFileAttachments, getTextAttachments, } from './message-formatting.js';
11
11
  import { processVoiceAttachment } from './voice-handler.js';
12
12
  import { isVoiceAttachment } from './voice-attachment.js';
13
- import { buildExternalDirectoryPermissionRules, initializeOpencodeForDirectory, } from './opencode.js';
13
+ import { initializeOpencodeForDirectory } from './opencode.js';
14
14
  import { getCompactSessionContext, getLastSessionId } from './markdown.js';
15
- import { getThreadSession, listTrackedTextChannels } from './database.js';
15
+ import { getThreadSession } from './database.js';
16
16
  import * as errore from 'errore';
17
17
  import { createLogger, LogPrefix } from './logger.js';
18
18
  import { notifyError } from './sentry.js';
@@ -88,23 +88,6 @@ async function getRepliedMessageContext({ message, }) {
88
88
  text: repliedText,
89
89
  };
90
90
  }
91
- export async function getChannelReferencePermissionRules({ message, }) {
92
- const channelIds = [...message.mentions.channels.keys()];
93
- if (channelIds.length === 0) {
94
- return [];
95
- }
96
- const mentionedChannelIds = new Set(channelIds);
97
- const referencedChannels = (await listTrackedTextChannels()).filter((channel) => {
98
- return mentionedChannelIds.has(channel.channel_id);
99
- });
100
- const resolvedDirectories = new Set(referencedChannels.map((channel) => channel.directory.replaceAll('\\', '/')));
101
- return [...resolvedDirectories].flatMap((directory) => {
102
- return buildExternalDirectoryPermissionRules({
103
- resolvedPattern: directory,
104
- action: 'allow',
105
- });
106
- });
107
- }
108
91
  /**
109
92
  * Pre-process a message in an existing thread (thread already has a session or
110
93
  * needs a new one). Handles voice transcription, text/file attachments, and
@@ -188,7 +171,6 @@ export async function preprocessExistingThreadMessage({ message, thread, project
188
171
  lastSessionContext,
189
172
  agents,
190
173
  });
191
- const permissionRules = await getChannelReferencePermissionRules({ message });
192
174
  if (voiceResult) {
193
175
  messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
194
176
  }
@@ -217,7 +199,6 @@ export async function preprocessExistingThreadMessage({ message, thread, project
217
199
  prompt,
218
200
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
219
201
  repliedMessage,
220
- permissionRules,
221
202
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
222
203
  agent: voiceResult?.agent,
223
204
  };
@@ -243,7 +224,6 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
243
224
  }
244
225
  let prompt = resolveMentions(message);
245
226
  const repliedMessage = await getRepliedMessageContext({ message });
246
- const permissionRules = await getChannelReferencePermissionRules({ message });
247
227
  const voiceResult = await processVoiceAttachment({
248
228
  message,
249
229
  thread,
@@ -293,7 +273,6 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
293
273
  prompt: finalPrompt,
294
274
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
295
275
  repliedMessage,
296
- permissionRules,
297
276
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
298
277
  agent: voiceResult?.agent,
299
278
  };
@@ -317,7 +296,6 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
317
296
  }
318
297
  let messageContent = resolveMentions(message);
319
298
  const repliedMessage = await getRepliedMessageContext({ message });
320
- const permissionRules = await getChannelReferencePermissionRules({ message });
321
299
  const voiceResult = await processVoiceAttachment({
322
300
  message,
323
301
  thread,
@@ -353,7 +331,6 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
353
331
  prompt,
354
332
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
355
333
  repliedMessage,
356
- permissionRules,
357
334
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
358
335
  agent: voiceResult?.agent,
359
336
  };
@@ -178,12 +178,16 @@ export function shouldRotateAuth(status, bodyText) {
178
178
  return true;
179
179
  if (status === 401 || status === 403)
180
180
  return true;
181
+ // xAI returns 402 Payment Required when Grok Build usage balance is exhausted
182
+ if (status === 402)
183
+ return true;
181
184
  return (haystack.includes('rate_limit') ||
182
185
  haystack.includes('rate limit') ||
183
186
  haystack.includes('usage limit') ||
184
187
  haystack.includes('usage_limit') ||
185
188
  haystack.includes('usage_limit_reached') ||
186
189
  haystack.includes('usage_not_included') ||
190
+ haystack.includes('balance exhausted') ||
187
191
  haystack.includes('invalid api key') ||
188
192
  haystack.includes('authentication_error') ||
189
193
  haystack.includes('permission_error'));
@@ -191,11 +195,13 @@ export function shouldRotateAuth(status, bodyText) {
191
195
  export function isRateLimitRetryMessage(message) {
192
196
  const haystack = message.toLowerCase();
193
197
  return (haystack.includes('429') ||
198
+ haystack.includes('402') ||
194
199
  haystack.includes('usage limit') ||
195
200
  haystack.includes('rate limit') ||
196
201
  haystack.includes('rate_limit') ||
197
202
  haystack.includes('usage_limit_reached') ||
198
- haystack.includes('usage_not_included'));
203
+ haystack.includes('usage_not_included') ||
204
+ haystack.includes('balance exhausted'));
199
205
  }
200
206
  export function isTokenRefreshError(message) {
201
207
  const haystack = message.toLowerCase();
@@ -134,6 +134,26 @@ export async function detectAndRememberNewOpenAIAccount() {
134
134
  }
135
135
  return undefined;
136
136
  }
137
+ // Check if the identity matches an existing account even though tokens
138
+ // don't match (happens after token refresh rotates both tokens).
139
+ // Without this check we'd show a false "account added" toast.
140
+ if (identity.email || identity.accountId) {
141
+ const matchesByIdentity = store.accounts.some((account) => {
142
+ if (identity.accountId && account.accountId === identity.accountId)
143
+ return true;
144
+ if (identity.email && account.email?.toLowerCase() === identity.email.toLowerCase())
145
+ return true;
146
+ return false;
147
+ });
148
+ if (matchesByIdentity) {
149
+ await withAuthStateLock(async () => {
150
+ const freshStore = await loadOpenAIAccountStore();
151
+ upsertAccount(freshStore, { ...auth, ...identity });
152
+ await saveOpenAIAccountStore(freshStore);
153
+ });
154
+ return undefined;
155
+ }
156
+ }
137
157
  // New account: upsert with identity
138
158
  await withAuthStateLock(async () => {
139
159
  const freshStore = await loadOpenAIAccountStore();