kimaki 0.25.0 → 0.27.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 (223) hide show
  1. package/dist/agent-model.e2e.test.js +157 -122
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/anthropic-account-identity.js +1 -0
  4. package/dist/anthropic-auth-plugin.js +1 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bin.js +73 -19
  7. package/dist/cli-commands/maintenance.js +46 -7
  8. package/dist/cli-commands/send.js +37 -3
  9. package/dist/cli-commands/session.js +183 -8
  10. package/dist/cli-commands/task.js +39 -8
  11. package/dist/cli-parsing.test.js +59 -3
  12. package/dist/cli-runner.js +94 -74
  13. package/dist/cli-runner.test.js +37 -1
  14. package/dist/cli-send-thread.e2e.test.js +1 -1
  15. package/dist/cli.js +32 -0
  16. package/dist/commands/abort.js +5 -1
  17. package/dist/commands/agent.js +93 -82
  18. package/dist/commands/ask-question.js +21 -32
  19. package/dist/commands/ask-question.test.js +43 -1
  20. package/dist/commands/login.js +6 -0
  21. package/dist/commands/merge-worktree.js +36 -10
  22. package/dist/commands/model-variant.js +7 -1
  23. package/dist/commands/model.js +32 -8
  24. package/dist/commands/multioauth.js +170 -1
  25. package/dist/commands/new-worktree.js +170 -102
  26. package/dist/commands/tasks.js +6 -0
  27. package/dist/commands/unset-model.js +5 -20
  28. package/dist/commands/user-command.js +6 -8
  29. package/dist/commands/worktrees.js +5 -13
  30. package/dist/config.js +16 -1
  31. package/dist/database.js +189 -3
  32. package/dist/db.js +22 -0
  33. package/dist/db.test.js +167 -1
  34. package/dist/discord-bot.js +41 -33
  35. package/dist/discord-command-registration.js +9 -1
  36. package/dist/discord-utils.js +13 -5
  37. package/dist/discord-utils.test.js +17 -1
  38. package/dist/errors.js +5 -0
  39. package/dist/exec-async.js +17 -5
  40. package/dist/external-directory-permissions.e2e.test.js +2 -2
  41. package/dist/file-edit-log.js +197 -0
  42. package/dist/file-edit-log.test.js +258 -0
  43. package/dist/format-tables.js +52 -0
  44. package/dist/format-tables.test.js +93 -0
  45. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  46. package/dist/gateway-proxy.e2e.test.js +5 -5
  47. package/dist/git-worktree-core.js +182 -17
  48. package/dist/ipc-tools-plugin.js +67 -6
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  50. package/dist/kimaki-opencode-plugin.js +9 -3
  51. package/dist/kimaki-workspace-adaptor.js +33 -10
  52. package/dist/logger.js +10 -1
  53. package/dist/markdown.js +3 -1
  54. package/dist/markdown.test.js +11 -3
  55. package/dist/message-formatting.js +95 -5
  56. package/dist/message-formatting.test.js +207 -2
  57. package/dist/oauth-rotation-shared.js +17 -1
  58. package/dist/openai-auth-plugin.js +1 -0
  59. package/dist/openai-auth-state.js +21 -0
  60. package/dist/opencode-command.test.js +60 -0
  61. package/dist/opencode.js +53 -12
  62. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  64. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  66. package/dist/queue-advanced-e2e-setup.js +78 -5
  67. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  68. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  69. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  70. package/dist/queue-advanced-question.e2e.test.js +31 -26
  71. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  72. package/dist/queue-delete-message.e2e.test.js +1 -1
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  74. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  75. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  76. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  77. package/dist/schema.js +53 -0
  78. package/dist/session-handler/event-stream-state.js +364 -0
  79. package/dist/session-handler/event-stream-state.test.js +844 -1
  80. package/dist/session-handler/model-utils.js +159 -5
  81. package/dist/session-handler/model-utils.test.js +345 -0
  82. package/dist/session-handler/thread-session-runtime.js +288 -165
  83. package/dist/session-sleep.test.js +224 -0
  84. package/dist/session-title-rename.test.js +7 -39
  85. package/dist/sleep.e2e.test.js +491 -0
  86. package/dist/startup-time.e2e.test.js +2 -1
  87. package/dist/store.js +3 -0
  88. package/dist/system-message.js +76 -9
  89. package/dist/system-message.test.js +103 -9
  90. package/dist/task-runner.js +235 -14
  91. package/dist/task-schedule.js +68 -0
  92. package/dist/task-schedule.test.js +219 -2
  93. package/dist/thread-message-queue.e2e.test.js +10 -169
  94. package/dist/undo-redo.e2e.test.js +2 -2
  95. package/dist/voice-attachment.js +23 -1
  96. package/dist/voice-message.e2e.test.js +10 -10
  97. package/dist/voice.test.js +55 -29
  98. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  99. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  100. package/dist/worktrees.js +226 -443
  101. package/dist/worktrees.test.js +435 -58
  102. package/dist/xai-auth-plugin.js +238 -0
  103. package/dist/xai-auth-state.js +234 -0
  104. package/dist/xai-auth-state.test.js +183 -0
  105. package/package.json +9 -7
  106. package/skills/errore/SKILL.md +18 -0
  107. package/skills/goke/SKILL.md +150 -10
  108. package/skills/holocron/SKILL.md +37 -0
  109. package/skills/npm-package/SKILL.md +55 -35
  110. package/skills/profano/SKILL.md +20 -3
  111. package/skills/spiceflow/SKILL.md +63 -4
  112. package/skills/strada/SKILL.md +39 -0
  113. package/skills/zele/SKILL.md +19 -0
  114. package/src/agent-model.e2e.test.ts +194 -152
  115. package/src/analytics.test.ts +30 -1
  116. package/src/analytics.ts +1 -0
  117. package/src/anthropic-account-identity.ts +1 -0
  118. package/src/anthropic-auth-plugin.ts +1 -0
  119. package/src/anthropic-auth-state.ts +1 -0
  120. package/src/bin.ts +78 -26
  121. package/src/cli-commands/maintenance.ts +59 -13
  122. package/src/cli-commands/send.ts +47 -3
  123. package/src/cli-commands/session.ts +203 -9
  124. package/src/cli-commands/task.ts +45 -20
  125. package/src/cli-parsing.test.ts +69 -3
  126. package/src/cli-runner.test.ts +65 -1
  127. package/src/cli-runner.ts +116 -93
  128. package/src/cli-send-thread.e2e.test.ts +1 -1
  129. package/src/cli.ts +53 -0
  130. package/src/commands/abort.ts +6 -1
  131. package/src/commands/agent.ts +126 -90
  132. package/src/commands/ask-question.test.ts +48 -1
  133. package/src/commands/ask-question.ts +21 -32
  134. package/src/commands/login.ts +6 -0
  135. package/src/commands/merge-worktree.ts +43 -12
  136. package/src/commands/model-variant.ts +8 -1
  137. package/src/commands/model.ts +43 -8
  138. package/src/commands/multioauth.ts +196 -2
  139. package/src/commands/new-worktree.ts +138 -55
  140. package/src/commands/tasks.ts +6 -0
  141. package/src/commands/unset-model.ts +5 -21
  142. package/src/commands/user-command.ts +6 -6
  143. package/src/commands/worktrees.ts +5 -12
  144. package/src/config.ts +18 -1
  145. package/src/database.ts +319 -3
  146. package/src/db.test.ts +188 -0
  147. package/src/db.ts +26 -0
  148. package/src/discord-bot.ts +42 -30
  149. package/src/discord-command-registration.ts +15 -1
  150. package/src/discord-utils.test.ts +19 -0
  151. package/src/discord-utils.ts +21 -6
  152. package/src/errors.ts +5 -0
  153. package/src/exec-async.ts +20 -11
  154. package/src/external-directory-permissions.e2e.test.ts +2 -2
  155. package/src/file-edit-log.test.ts +287 -0
  156. package/src/file-edit-log.ts +283 -0
  157. package/src/format-tables.test.ts +101 -0
  158. package/src/format-tables.ts +75 -0
  159. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  160. package/src/gateway-proxy.e2e.test.ts +5 -5
  161. package/src/git-worktree-core.ts +247 -20
  162. package/src/ipc-tools-plugin.ts +79 -8
  163. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  164. package/src/kimaki-opencode-plugin.ts +9 -3
  165. package/src/kimaki-workspace-adaptor.ts +40 -11
  166. package/src/logger.ts +13 -4
  167. package/src/markdown.test.ts +13 -3
  168. package/src/markdown.ts +4 -1
  169. package/src/message-formatting.test.ts +271 -2
  170. package/src/message-formatting.ts +110 -5
  171. package/src/oauth-rotation-shared.ts +16 -1
  172. package/src/openai-auth-plugin.ts +1 -0
  173. package/src/openai-auth-state.ts +20 -0
  174. package/src/opencode-command.test.ts +81 -0
  175. package/src/opencode.ts +71 -11
  176. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  177. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  178. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  179. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  180. package/src/queue-advanced-e2e-setup.ts +83 -4
  181. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  182. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  183. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  184. package/src/queue-advanced-question.e2e.test.ts +35 -29
  185. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  186. package/src/queue-delete-message.e2e.test.ts +1 -1
  187. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  188. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  189. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  190. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  191. package/src/schema.sql +29 -1
  192. package/src/schema.ts +56 -0
  193. package/src/session-handler/event-stream-state.test.ts +920 -0
  194. package/src/session-handler/event-stream-state.ts +546 -0
  195. package/src/session-handler/model-utils.test.ts +415 -0
  196. package/src/session-handler/model-utils.ts +250 -5
  197. package/src/session-handler/thread-session-runtime.ts +346 -200
  198. package/src/session-sleep.test.ts +294 -0
  199. package/src/session-title-rename.test.ts +9 -48
  200. package/src/sleep.e2e.test.ts +562 -0
  201. package/src/startup-time.e2e.test.ts +5 -1
  202. package/src/store.ts +21 -0
  203. package/src/system-message.test.ts +114 -9
  204. package/src/system-message.ts +85 -10
  205. package/src/task-runner.ts +304 -13
  206. package/src/task-schedule.test.ts +248 -2
  207. package/src/task-schedule.ts +118 -2
  208. package/src/thread-message-queue.e2e.test.ts +10 -202
  209. package/src/undo-redo.e2e.test.ts +2 -2
  210. package/src/voice-attachment.ts +29 -1
  211. package/src/voice-message.e2e.test.ts +10 -10
  212. package/src/voice.test.ts +63 -29
  213. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  214. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  215. package/src/worktrees.test.ts +505 -64
  216. package/src/worktrees.ts +257 -603
  217. package/src/xai-auth-plugin.ts +303 -0
  218. package/src/xai-auth-state.test.ts +224 -0
  219. package/src/xai-auth-state.ts +294 -0
  220. package/dist/message-finish-field.e2e.test.js +0 -169
  221. package/dist/subagent-rate-limit-plugin.js +0 -173
  222. package/src/message-finish-field.e2e.test.ts +0 -196
  223. package/src/subagent-rate-limit-plugin.ts +0 -218
@@ -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
  import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
4
7
  import type { Part } from '@opencode-ai/sdk/v2'
5
8
 
@@ -48,6 +51,105 @@ describe('formatPart', () => {
48
51
  })
49
52
  })
50
53
 
54
+ describe('formatTaskToolTitle', () => {
55
+ function taskPart({
56
+ status,
57
+ input = {},
58
+ title = '',
59
+ sessionId,
60
+ }: {
61
+ status: 'running' | 'completed'
62
+ input?: { description?: string; subagent_type?: string }
63
+ title?: string
64
+ sessionId?: string
65
+ }): Extract<Part, { type: 'tool' }> {
66
+ const base = {
67
+ id: 'prt_task',
68
+ type: 'tool' as const,
69
+ tool: 'task',
70
+ callID: 'call_task',
71
+ sessionID: 'ses_parent',
72
+ messageID: 'msg_assistant',
73
+ }
74
+ if (status === 'completed') {
75
+ return {
76
+ ...base,
77
+ state: {
78
+ status,
79
+ input,
80
+ output: '',
81
+ title,
82
+ metadata: sessionId ? { sessionId } : {},
83
+ time: { start: 1, end: 2 },
84
+ },
85
+ }
86
+ }
87
+ return {
88
+ ...base,
89
+ state: {
90
+ status,
91
+ input,
92
+ title,
93
+ metadata: sessionId ? { sessionId } : {},
94
+ time: { start: 1 },
95
+ },
96
+ }
97
+ }
98
+
99
+ test('uses the running task title when OpenAI omits the description', () => {
100
+ expect(
101
+ formatTaskToolTitle(
102
+ taskPart({
103
+ status: 'running',
104
+ input: { subagent_type: 'general' },
105
+ title: 'Classify pending changes',
106
+ sessionId: 'ses_child',
107
+ }),
108
+ ),
109
+ ).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`)
110
+ })
111
+
112
+ test('prefers input description on running parts', () => {
113
+ expect(
114
+ formatTaskToolTitle(
115
+ taskPart({
116
+ status: 'running',
117
+ input: { description: 'inspect repo', subagent_type: 'explore' },
118
+ title: 'ignored title',
119
+ sessionId: 'ses_child',
120
+ }),
121
+ ),
122
+ ).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`)
123
+ })
124
+
125
+ test('does not format completed parts so Discord does not post the line at the end', () => {
126
+ expect(
127
+ formatTaskToolTitle(
128
+ taskPart({
129
+ status: 'completed',
130
+ input: { subagent_type: 'general' },
131
+ title: 'Classify pending changes',
132
+ sessionId: 'ses_child',
133
+ }),
134
+ ),
135
+ ).toBe('')
136
+ })
137
+
138
+ test('shows queued task calls before the child session exists', () => {
139
+ expect(
140
+ formatTaskToolTitle(
141
+ taskPart({
142
+ status: 'running',
143
+ input: {
144
+ description: 'audit customer pages',
145
+ subagent_type: 'general',
146
+ },
147
+ }),
148
+ ),
149
+ ).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`)
150
+ })
151
+ })
152
+
51
153
  describe('formatBashToolTitle', () => {
52
154
  test('short single-line command shown in full', () => {
53
155
  expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`)
@@ -429,3 +531,170 @@ describe('serializeMessageSnapshots', () => {
429
531
  `)
430
532
  })
431
533
  })
534
+
535
+ describe('getTextAttachments', () => {
536
+ const originalFetch = globalThis.fetch
537
+
538
+ afterEach(() => {
539
+ globalThis.fetch = originalFetch
540
+ })
541
+
542
+ function stubFetch(impl: (url: string) => Promise<Response>) {
543
+ const fetchFn = async (input: string | URL | Request) => impl(String(input))
544
+ fetchFn.preconnect = () => {}
545
+ globalThis.fetch = fetchFn
546
+ }
547
+
548
+ function messageWithAttachments(
549
+ attachments: Array<{
550
+ id: string
551
+ name: string
552
+ contentType: string
553
+ url: string
554
+ size: number
555
+ }>,
556
+ ) {
557
+ return {
558
+ attachments: new Map(attachments.map((attachment) => {
559
+ return [attachment.id, attachment]
560
+ })),
561
+ } as unknown as Message
562
+ }
563
+
564
+ function snapshotAttachments(result: string) {
565
+ return result.replaceAll(getDataDir(), '<dataDir>')
566
+ }
567
+
568
+ test('inlines small text files and saves them locally', async () => {
569
+ stubFetch(async () => {
570
+ return new Response('hello from file', { status: 200 })
571
+ })
572
+
573
+ const result = await getTextAttachments(
574
+ messageWithAttachments([
575
+ {
576
+ id: 'att1',
577
+ name: 'notes.txt',
578
+ contentType: 'text/plain',
579
+ url: 'https://cdn.example/notes.txt',
580
+ size: 16,
581
+ },
582
+ ]),
583
+ )
584
+
585
+ const savedPath = path.join(getDataDir(), 'attachments', 'att1-notes.txt')
586
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('hello from file')
587
+ expect(result).toContain('https://cdn.example/notes.txt')
588
+ expect(result).toContain(savedPath)
589
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
590
+ "<attachment filename="notes.txt" mime="text/plain" size="16" url="https://cdn.example/notes.txt" path="<dataDir>/attachments/att1-notes.txt">
591
+ hello from file
592
+ </attachment>"
593
+ `)
594
+ })
595
+
596
+ test('saves large text files locally without inlining contents', async () => {
597
+ stubFetch(async () => {
598
+ return new Response('SHOULD NOT BE INLINED', { status: 200 })
599
+ })
600
+
601
+ const result = await getTextAttachments(
602
+ messageWithAttachments([
603
+ {
604
+ id: 'att2',
605
+ name: 'huge.log',
606
+ contentType: 'text/plain',
607
+ url: 'https://cdn.discordapp.com/attachments/1/2/huge.log',
608
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
609
+ },
610
+ ]),
611
+ )
612
+
613
+ const savedPath = path.join(getDataDir(), 'attachments', 'att2-huge.log')
614
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('SHOULD NOT BE INLINED')
615
+ expect(result).not.toContain('SHOULD NOT BE INLINED')
616
+ expect(result).toContain('https://cdn.discordapp.com/attachments/1/2/huge.log')
617
+ expect(result).toContain(savedPath)
618
+ expect(result).toContain('text/plain')
619
+ expect(result).toContain(String(TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1))
620
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
621
+ "<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">
622
+ This file is large (64 KB, text/plain). Contents were not inlined to save context. Read the local path.
623
+ </attachment>"
624
+ `)
625
+ })
626
+
627
+ test('still inlines large prompt.md attachments from kimaki send', async () => {
628
+ stubFetch(async () => {
629
+ return new Response('the actual long prompt', { status: 200 })
630
+ })
631
+
632
+ const result = await getTextAttachments(
633
+ messageWithAttachments([
634
+ {
635
+ id: 'att3',
636
+ name: 'prompt.md',
637
+ contentType: 'text/markdown',
638
+ url: 'https://cdn.example/prompt.md',
639
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
640
+ },
641
+ ]),
642
+ )
643
+
644
+ const savedPath = path.join(getDataDir(), 'attachments', 'att3-prompt.md')
645
+ expect(fs.readFileSync(savedPath, 'utf8')).toBe('the actual long prompt')
646
+ expect(result).toContain('the actual long prompt')
647
+ expect(result).toContain('https://cdn.example/prompt.md')
648
+ expect(result).toContain(savedPath)
649
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
650
+ "<attachment filename="prompt.md" mime="text/markdown" size="65537" url="https://cdn.example/prompt.md" path="<dataDir>/attachments/att3-prompt.md">
651
+ the actual long prompt
652
+ </attachment>"
653
+ `)
654
+ })
655
+
656
+ test('keeps small files inlined next to large file references', async () => {
657
+ stubFetch(async (url) => {
658
+ if (url.includes('small.txt')) {
659
+ return new Response('tiny', { status: 200 })
660
+ }
661
+ return new Response('SHOULD NOT BE INLINED', { status: 200 })
662
+ })
663
+
664
+ const result = await getTextAttachments(
665
+ messageWithAttachments([
666
+ {
667
+ id: 'att4',
668
+ name: 'small.txt',
669
+ contentType: 'text/plain',
670
+ url: 'https://cdn.example/small.txt',
671
+ size: 4,
672
+ },
673
+ {
674
+ id: 'att5',
675
+ name: 'dump.json',
676
+ contentType: 'application/json',
677
+ url: 'https://cdn.example/dump.json',
678
+ size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 50,
679
+ },
680
+ ]),
681
+ )
682
+
683
+ expect(result).toContain('tiny')
684
+ expect(result).not.toContain('SHOULD NOT BE INLINED')
685
+ expect(result).toContain('https://cdn.example/small.txt')
686
+ expect(result).toContain('https://cdn.example/dump.json')
687
+ expect(result).toContain(path.join(getDataDir(), 'attachments', 'att4-small.txt'))
688
+ expect(result).toContain(path.join(getDataDir(), 'attachments', 'att5-dump.json'))
689
+ expect(result).toContain('application/json')
690
+ expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
691
+ "<attachment filename="small.txt" mime="text/plain" size="4" url="https://cdn.example/small.txt" path="<dataDir>/attachments/att4-small.txt">
692
+ tiny
693
+ </attachment>
694
+
695
+ <attachment filename="dump.json" mime="application/json" size="65586" url="https://cdn.example/dump.json" path="<dataDir>/attachments/att5-dump.json" large="true">
696
+ This file is large (64 KB, application/json). Contents were not inlined to save context. Read the local path.
697
+ </attachment>"
698
+ `)
699
+ })
700
+ })
@@ -10,10 +10,13 @@ export type DiscordFileAttachment = FilePartInput & {
10
10
  sourceUrl?: string
11
11
  }
12
12
 
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
13
15
  import { createLogger, LogPrefix } from './logger.js'
14
- import { FetchError } from './errors.js'
16
+ import { FetchError, FilesystemOperationError } from './errors.js'
15
17
  import { processImage } from './image-utils.js'
16
18
  import { parsePatchFileCounts } from './patch-text-parser.js'
19
+ import { getDataDir } from './config.js'
17
20
 
18
21
  // Generic message type compatible with both v1 and v2 SDK
19
22
  type GenericSessionMessage = {
@@ -261,6 +264,52 @@ export function isTextMimeType(contentType: string | null): boolean {
261
264
  return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
262
265
  }
263
266
 
267
+ // Small Discord "send as file" prompts stay inlined. Bigger dumps only get a URL.
268
+ export const TEXT_ATTACHMENT_INLINE_LIMIT_BYTES = 64 * 1024
269
+ const KIMAKI_SEND_PROMPT_ATTACHMENT_NAME = 'prompt.md'
270
+
271
+ function formatAttachmentSize(bytes: number): string {
272
+ if (bytes < 1024) return `${bytes} B`
273
+ if (bytes < 1024 * 1024) {
274
+ const kb = bytes / 1024
275
+ return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`
276
+ }
277
+ const mb = bytes / (1024 * 1024)
278
+ return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`
279
+ }
280
+
281
+ function shouldInlineTextAttachment(attachment: {
282
+ name: string
283
+ size: number
284
+ }): boolean {
285
+ if (attachment.name === KIMAKI_SEND_PROMPT_ATTACHMENT_NAME) return true
286
+ return attachment.size <= TEXT_ATTACHMENT_INLINE_LIMIT_BYTES
287
+ }
288
+
289
+ function textAttachmentAttrs(
290
+ attachment: { name: string; contentType: string | null; size: number; url: string },
291
+ extra: string[] = [],
292
+ ): string {
293
+ return [
294
+ `filename="${attachment.name}"`,
295
+ `mime="${attachment.contentType}"`,
296
+ `size="${attachment.size}"`,
297
+ `url="${attachment.url}"`,
298
+ ...extra,
299
+ ].join(' ')
300
+ }
301
+
302
+ function safeAttachmentBasename(name: string): string {
303
+ const sanitized = path.basename(name).replace(/\0/g, '')
304
+ if (!sanitized || sanitized === '.' || sanitized === '..') return 'attachment'
305
+ return sanitized
306
+ }
307
+
308
+ function localAttachmentPath(attachment: { id?: string; name: string; url: string }): string {
309
+ const id = attachment.id || attachment.url.slice(-12).replace(/[^a-zA-Z0-9]/g, '')
310
+ return path.join(getDataDir(), 'attachments', `${id}-${safeAttachmentBasename(attachment.name)}`)
311
+ }
312
+
264
313
  export async function getTextAttachments(message: Message): Promise<string> {
265
314
  const textAttachments = Array.from(message.attachments.values()).filter(
266
315
  (attachment) => isTextMimeType(attachment.contentType),
@@ -270,18 +319,40 @@ export async function getTextAttachments(message: Message): Promise<string> {
270
319
  return ''
271
320
  }
272
321
 
322
+ const attachmentsDir = path.join(getDataDir(), 'attachments')
323
+ fs.mkdirSync(attachmentsDir, { recursive: true })
324
+
273
325
  const textContents = await Promise.all(
274
326
  textAttachments.map(async (attachment) => {
275
327
  const response = await fetch(attachment.url)
276
328
  .catch((e) => new FetchError({ url: attachment.url, cause: e }))
277
329
  if (response instanceof Error) {
278
- return `<attachment filename="${attachment.name}" error="${response.message}" />`
330
+ return `<attachment ${textAttachmentAttrs(attachment)} error="${response.message}" />`
279
331
  }
280
332
  if (!response.ok) {
281
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
333
+ return `<attachment ${textAttachmentAttrs(attachment)} error="Failed to fetch: ${response.status}" />`
282
334
  }
283
- const text = await response.text()
284
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
335
+
336
+ const buffer = Buffer.from(await response.arrayBuffer())
337
+ const savedPath = localAttachmentPath(attachment)
338
+ const written = await fs.promises
339
+ .writeFile(savedPath, buffer)
340
+ .catch((e) => new FilesystemOperationError({ operation: 'write attachment', cause: e }))
341
+ if (written instanceof Error) {
342
+ logger.error(`Failed to save attachment ${attachment.name}:`, written.message)
343
+ }
344
+ const pathAttr = written instanceof Error ? [] : [`path="${savedPath}"`]
345
+
346
+ if (!shouldInlineTextAttachment(attachment)) {
347
+ const sizeLabel = formatAttachmentSize(attachment.size)
348
+ return [
349
+ `<attachment ${textAttachmentAttrs(attachment, [...pathAttr, 'large="true"'])}>`,
350
+ `This file is large (${sizeLabel}, ${attachment.contentType || 'unknown type'}). Contents were not inlined to save context. Read the local path.`,
351
+ `</attachment>`,
352
+ ].join('\n')
353
+ }
354
+
355
+ return `<attachment ${textAttachmentAttrs(attachment, pathAttr)}>\n${buffer.toString('utf8')}\n</attachment>`
285
356
  }),
286
357
  )
287
358
 
@@ -495,6 +566,22 @@ export function getToolSummaryText(part: Part): string {
495
566
  return prompt ? `*${escapeInlineMarkdown(prompt.slice(0, 60))}*` : ''
496
567
  }
497
568
 
569
+ // LESSON: tool parts are sent to Discord at `running` status (see
570
+ // shouldSendPart in thread-session-runtime), so `state.title` from the tool
571
+ // result is never rendered here. Everything shown must come from state.input.
572
+ // `until` is already absolute; `duration` is relative, so say "for 2h" —
573
+ // never "until 2h".
574
+ if (part.tool.endsWith('kimaki_sleep')) {
575
+ const until = (part.state.input?.until as string) || ''
576
+ const duration = (part.state.input?.duration as string) || ''
577
+ const reason = (part.state.input?.reason as string) || ''
578
+ const when = until ? `until ${until}` : duration ? `for ${duration}` : ''
579
+ const reasonText = reason ? `_${escapeInlineMarkdown(reason)}_` : ''
580
+ return [when && escapeInlineMarkdown(when), reasonText]
581
+ .filter(Boolean)
582
+ .join(' ')
583
+ }
584
+
498
585
  if (!part.state.input) return ''
499
586
 
500
587
  const inputFields = Object.entries(part.state.input)
@@ -536,6 +623,24 @@ export function formatTodoList(part: Part): string {
536
623
  return `${num} **${escapeInlineMarkdown(content)}**`
537
624
  }
538
625
 
626
+ export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): string {
627
+ // Running only. The child session can be created later when many tasks queue.
628
+ if (part.tool !== 'task' || part.state.status !== 'running') return ''
629
+
630
+ const description = part.state.input?.description
631
+ const stateTitle = part.state.title
632
+ const title = typeof description === 'string' && description
633
+ ? description
634
+ : typeof stateTitle === 'string'
635
+ ? stateTitle
636
+ : ''
637
+ if (!title) return ''
638
+
639
+ const subagentType = part.state.input?.subagent_type
640
+ const agent = typeof subagentType === 'string' ? subagentType : 'task'
641
+ return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
642
+ }
643
+
539
644
  export function formatPart(part: Part, prefix?: string): string {
540
645
  const pfx = prefix ? `${prefix} ⋅ ` : ''
541
646
 
@@ -1,3 +1,13 @@
1
+ /**
2
+ * LEGACY. Superseded by @subrouter/opencode, registered alongside the kimaki
3
+ * plugin in opencode.ts. This whole rotation path only cycles accounts inside
4
+ * one provider; subrouter also fails over to a different provider when every
5
+ * account of one is exhausted.
6
+ *
7
+ * Kept because opencode ships no Claude Pro/Max auth of its own, so deleting
8
+ * it would strand anyone still picking `anthropic/*` directly. To move to
9
+ * subrouter, log in again through /login and pick Subrouter.
10
+ */
1
11
  /**
2
12
  * Shared utilities for multi-provider OAuth account rotation.
3
13
  * Used by both anthropic-auth-state.ts and openai-auth-state.ts to avoid
@@ -245,6 +255,8 @@ export function shouldRotateAuth(status: number, bodyText: string) {
245
255
  const haystack = bodyText.toLowerCase()
246
256
  if (status === 429) return true
247
257
  if (status === 401 || status === 403) return true
258
+ // xAI returns 402 Payment Required when Grok Build usage balance is exhausted
259
+ if (status === 402) return true
248
260
  return (
249
261
  haystack.includes('rate_limit') ||
250
262
  haystack.includes('rate limit') ||
@@ -252,6 +264,7 @@ export function shouldRotateAuth(status: number, bodyText: string) {
252
264
  haystack.includes('usage_limit') ||
253
265
  haystack.includes('usage_limit_reached') ||
254
266
  haystack.includes('usage_not_included') ||
267
+ haystack.includes('balance exhausted') ||
255
268
  haystack.includes('invalid api key') ||
256
269
  haystack.includes('authentication_error') ||
257
270
  haystack.includes('permission_error')
@@ -262,11 +275,13 @@ export function isRateLimitRetryMessage(message: string) {
262
275
  const haystack = message.toLowerCase()
263
276
  return (
264
277
  haystack.includes('429') ||
278
+ haystack.includes('402') ||
265
279
  haystack.includes('usage limit') ||
266
280
  haystack.includes('rate limit') ||
267
281
  haystack.includes('rate_limit') ||
268
282
  haystack.includes('usage_limit_reached') ||
269
- haystack.includes('usage_not_included')
283
+ haystack.includes('usage_not_included') ||
284
+ haystack.includes('balance exhausted')
270
285
  )
271
286
  }
272
287
 
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth rotation plugin for OpenCode.
3
4
  *
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth account store and rotation.
3
4
  * Mirrors anthropic-auth-state.ts but for OpenAI/Codex OAuth accounts.
@@ -183,6 +184,25 @@ export async function detectAndRememberNewOpenAIAccount(): Promise<AccountIdenti
183
184
  return undefined
184
185
  }
185
186
 
187
+ // Check if the identity matches an existing account even though tokens
188
+ // don't match (happens after token refresh rotates both tokens).
189
+ // Without this check we'd show a false "account added" toast.
190
+ if (identity.email || identity.accountId) {
191
+ const matchesByIdentity = store.accounts.some((account) => {
192
+ if (identity.accountId && account.accountId === identity.accountId) return true
193
+ if (identity.email && account.email?.toLowerCase() === identity.email.toLowerCase()) return true
194
+ return false
195
+ })
196
+ if (matchesByIdentity) {
197
+ await withAuthStateLock(async () => {
198
+ const freshStore = await loadOpenAIAccountStore()
199
+ upsertAccount(freshStore, { ...auth, ...identity })
200
+ await saveOpenAIAccountStore(freshStore)
201
+ })
202
+ return undefined
203
+ }
204
+ }
205
+
186
206
  // New account: upsert with identity
187
207
  await withAuthStateLock(async () => {
188
208
  const freshStore = await loadOpenAIAccountStore()
@@ -1,6 +1,7 @@
1
1
  // Regression tests for Windows OpenCode command resolution and spawn args.
2
2
 
3
3
  import fs from 'node:fs'
4
+ import { createRequire } from 'node:module'
4
5
  import os from 'node:os'
5
6
  import path from 'node:path'
6
7
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
@@ -45,6 +46,86 @@ describe('selectResolvedCommand', () => {
45
46
  })
46
47
  })
47
48
 
49
+ describe('buildOpencodeServeArgs', () => {
50
+ test('always passes --hostname so opencode.json cannot bind 0.0.0.0', async () => {
51
+ const { buildOpencodeServeArgs } = await import('./opencode.js')
52
+ expect(buildOpencodeServeArgs({ port: 4096 })).toEqual([
53
+ 'serve',
54
+ '--port',
55
+ '4096',
56
+ '--hostname',
57
+ '127.0.0.1',
58
+ '--print-logs',
59
+ '--log-level',
60
+ 'WARN',
61
+ ])
62
+ })
63
+
64
+ test('passes --hostname when set', async () => {
65
+ const { buildOpencodeServeArgs } = await import('./opencode.js')
66
+ expect(
67
+ buildOpencodeServeArgs({ port: 4096, hostname: '0.0.0.0' }),
68
+ ).toEqual([
69
+ 'serve',
70
+ '--port',
71
+ '4096',
72
+ '--hostname',
73
+ '0.0.0.0',
74
+ '--print-logs',
75
+ '--log-level',
76
+ 'WARN',
77
+ ])
78
+ })
79
+ })
80
+
81
+ describe('published runtime artifacts', () => {
82
+ test('lists @subrouter/opencode as a runtime dependency', () => {
83
+ const pkg = JSON.parse(
84
+ fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'),
85
+ ) as { dependencies?: Record<string, string> }
86
+ expect(pkg.dependencies?.['@subrouter/opencode']).toMatch(/^(workspace:\^|\^)/)
87
+ })
88
+ })
89
+
90
+ describe('resolveSubrouterPluginSpec', () => {
91
+ test('uses npm package identity in production for OpenCode deduplication', async () => {
92
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js')
93
+ const require = createRequire(import.meta.url)
94
+ const packageJsonPath = require.resolve('@subrouter/opencode/package.json')
95
+ const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version
96
+ expect(resolveSubrouterPluginSpec({ isDev: false })).toBe(
97
+ `@subrouter/opencode@${version}`,
98
+ )
99
+ })
100
+
101
+ test('loads workspace source directly in development', async () => {
102
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js')
103
+ expect(resolveSubrouterPluginSpec({ isDev: true })).toMatch(
104
+ /^file:.*\/subrouter\/opencode\/dist\/index\.js$/,
105
+ )
106
+ })
107
+ })
108
+
109
+ describe('publicOpencodeBindRequiresPassword', () => {
110
+ test('allows loopback without a password', async () => {
111
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js')
112
+ expect(publicOpencodeBindRequiresPassword({ hostname: null })).toBe(false)
113
+ expect(publicOpencodeBindRequiresPassword({ hostname: '127.0.0.1' })).toBe(
114
+ false,
115
+ )
116
+ expect(publicOpencodeBindRequiresPassword({ hostname: 'localhost' })).toBe(
117
+ false,
118
+ )
119
+ })
120
+
121
+ test('requires a password for 0.0.0.0', async () => {
122
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js')
123
+ expect(publicOpencodeBindRequiresPassword({ hostname: '0.0.0.0' })).toBe(
124
+ true,
125
+ )
126
+ })
127
+ })
128
+
48
129
  describe('getSpawnCommandAndArgs', () => {
49
130
  test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
50
131
  expect(