kimaki 1.4.0 → 1.6.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 (326) hide show
  1. package/assets/fonts/Geist-Bold.ttf +0 -0
  2. package/assets/fonts/Geist-Regular.ttf +0 -0
  3. package/assets/fonts/GeistMono-Regular.ttf +0 -0
  4. package/assets/fonts/OFL.txt +92 -0
  5. package/dist/agent-ui.e2e.test.js +27 -13
  6. package/dist/agent-ui.e2e.test.js.map +1 -1
  7. package/dist/agent-ui.js +2 -1
  8. package/dist/agent-ui.js.map +1 -1
  9. package/dist/analytics.e2e.test.js +1 -1
  10. package/dist/analytics.e2e.test.js.map +1 -1
  11. package/dist/autostart.d.ts +4 -0
  12. package/dist/autostart.js +55 -10
  13. package/dist/autostart.js.map +1 -1
  14. package/dist/bot.d.ts +10 -1
  15. package/dist/bot.js +14 -6
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +1 -1
  19. package/dist/builtin-skills.js +1 -1
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +3 -4
  22. package/dist/builtin-skills.test.js.map +1 -1
  23. package/dist/catalog-refresh.e2e.test.js +2 -2
  24. package/dist/cli/bot.js +25 -14
  25. package/dist/cli/bot.js.map +1 -1
  26. package/dist/cli/discord.js +32 -6
  27. package/dist/cli/discord.js.map +1 -1
  28. package/dist/cli/schedule.js +23 -9
  29. package/dist/cli/schedule.js.map +1 -1
  30. package/dist/cli/session.js +37 -12
  31. package/dist/cli/session.js.map +1 -1
  32. package/dist/cli/shared.d.ts +5 -3
  33. package/dist/cli/shared.js +45 -10
  34. package/dist/cli/shared.js.map +1 -1
  35. package/dist/cli-discord.e2e.test.js +13 -8
  36. package/dist/cli-discord.e2e.test.js.map +1 -1
  37. package/dist/cli-lock.e2e.test.js +87 -20
  38. package/dist/cli-lock.e2e.test.js.map +1 -1
  39. package/dist/cli-read.e2e.test.js +18 -9
  40. package/dist/cli-read.e2e.test.js.map +1 -1
  41. package/dist/cli-sessions.e2e.test.js +4 -3
  42. package/dist/cli-sessions.e2e.test.js.map +1 -1
  43. package/dist/cli.js +2 -0
  44. package/dist/cli.js.map +1 -1
  45. package/dist/commands/preference-commands.d.ts +4 -0
  46. package/dist/commands/preference-commands.js +38 -4
  47. package/dist/commands/preference-commands.js.map +1 -1
  48. package/dist/commands/session-commands.js +11 -8
  49. package/dist/commands/session-commands.js.map +1 -1
  50. package/dist/commands/thread-commands.js +94 -24
  51. package/dist/commands/thread-commands.js.map +1 -1
  52. package/dist/commands/worktree-commands.d.ts +4 -5
  53. package/dist/commands/worktree-commands.js +8 -7
  54. package/dist/commands/worktree-commands.js.map +1 -1
  55. package/dist/commands.e2e.test.js +25 -21
  56. package/dist/commands.e2e.test.js.map +1 -1
  57. package/dist/coordinator-agents.e2e.test.js +135 -0
  58. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  59. package/dist/coordinator-idle.e2e.test.js +100 -0
  60. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  61. package/dist/coordinator-queue.e2e.test.js +244 -0
  62. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  63. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  64. package/dist/coordinator-warmup.e2e.test.js +131 -0
  65. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  66. package/dist/coordinator.d.ts +6 -0
  67. package/dist/coordinator.e2e.test.d.ts +1 -0
  68. package/dist/coordinator.e2e.test.js +505 -0
  69. package/dist/coordinator.e2e.test.js.map +1 -0
  70. package/dist/coordinator.js +164 -0
  71. package/dist/coordinator.js.map +1 -0
  72. package/dist/db.d.ts +75 -0
  73. package/dist/db.test.js +1 -0
  74. package/dist/db.test.js.map +1 -1
  75. package/dist/diagram.d.ts +11 -0
  76. package/dist/diagram.js +232 -0
  77. package/dist/diagram.js.map +1 -0
  78. package/dist/diagram.test.d.ts +1 -0
  79. package/dist/diagram.test.js +52 -0
  80. package/dist/diagram.test.js.map +1 -0
  81. package/dist/duration.js +13 -5
  82. package/dist/duration.js.map +1 -1
  83. package/dist/effects.d.ts +6 -1
  84. package/dist/effects.js +97 -26
  85. package/dist/effects.js.map +1 -1
  86. package/dist/event-loop.d.ts +1 -1
  87. package/dist/event-loop.js +73 -43
  88. package/dist/event-loop.js.map +1 -1
  89. package/dist/format-parts.d.ts +21 -4
  90. package/dist/format-parts.js +40 -23
  91. package/dist/format-parts.js.map +1 -1
  92. package/dist/gateway-replay.e2e.test.js +4 -4
  93. package/dist/harness.e2e.test.js +2 -2
  94. package/dist/harness.e2e.test.js.map +1 -1
  95. package/dist/hello-thread.e2e.test.js +56 -5
  96. package/dist/hello-thread.e2e.test.js.map +1 -1
  97. package/dist/ingress.js +68 -13
  98. package/dist/ingress.js.map +1 -1
  99. package/dist/interaction-context.d.ts +4 -4
  100. package/dist/interaction-context.js +9 -7
  101. package/dist/interaction-context.js.map +1 -1
  102. package/dist/interrupt.e2e.test.js +8 -5
  103. package/dist/interrupt.e2e.test.js.map +1 -1
  104. package/dist/legacy.e2e.test.js +1 -1
  105. package/dist/lock-routes.d.ts +68 -16
  106. package/dist/lock-routes.js +68 -8
  107. package/dist/lock-routes.js.map +1 -1
  108. package/dist/lock-server.d.ts +3 -2
  109. package/dist/lock-server.js +23 -7
  110. package/dist/lock-server.js.map +1 -1
  111. package/dist/main.d.ts +4 -2
  112. package/dist/main.js +13 -3
  113. package/dist/main.js.map +1 -1
  114. package/dist/markdown/markdown.test.js +4 -4
  115. package/dist/markdown/render-markdown.js +6 -5
  116. package/dist/markdown/render-markdown.js.map +1 -1
  117. package/dist/onboarding-cli.e2e.test.js +7 -6
  118. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  119. package/dist/onboarding.d.ts +3 -10
  120. package/dist/onboarding.e2e.test.js +8 -10
  121. package/dist/onboarding.e2e.test.js.map +1 -1
  122. package/dist/onboarding.js +14 -24
  123. package/dist/onboarding.js.map +1 -1
  124. package/dist/permissions.e2e.test.js +3 -3
  125. package/dist/plugin/coordinator.d.ts +4 -0
  126. package/dist/plugin/coordinator.js +389 -0
  127. package/dist/plugin/coordinator.js.map +1 -0
  128. package/dist/plugin/index.js +76 -17
  129. package/dist/plugin/index.js.map +1 -1
  130. package/dist/plugin.e2e.test.js +1 -1
  131. package/dist/preference-commands.e2e.test.js +103 -24
  132. package/dist/preference-commands.e2e.test.js.map +1 -1
  133. package/dist/prompt.d.ts +21 -8
  134. package/dist/prompt.js +64 -48
  135. package/dist/prompt.js.map +1 -1
  136. package/dist/questions.e2e.test.js +13 -13
  137. package/dist/queue-sources.e2e.test.d.ts +1 -0
  138. package/dist/queue-sources.e2e.test.js +168 -0
  139. package/dist/queue-sources.e2e.test.js.map +1 -0
  140. package/dist/queue.d.ts +9 -1
  141. package/dist/queue.e2e.test.js +48 -30
  142. package/dist/queue.e2e.test.js.map +1 -1
  143. package/dist/queue.js +97 -24
  144. package/dist/queue.js.map +1 -1
  145. package/dist/queue.test.d.ts +1 -0
  146. package/dist/queue.test.js +57 -0
  147. package/dist/queue.test.js.map +1 -0
  148. package/dist/remote-send.e2e.test.js +6 -6
  149. package/dist/remote-send.js +2 -1
  150. package/dist/remote-send.js.map +1 -1
  151. package/dist/renderer.e2e.test.js +7 -7
  152. package/dist/restart.e2e.test.js +2 -2
  153. package/dist/routines.e2e.test.js +2 -2
  154. package/dist/scheduler.d.ts +1 -1
  155. package/dist/scheduler.e2e.test.js +43 -20
  156. package/dist/scheduler.e2e.test.js.map +1 -1
  157. package/dist/scheduler.js +4 -2
  158. package/dist/scheduler.js.map +1 -1
  159. package/dist/schema-sql.d.ts +1 -1
  160. package/dist/schema-sql.js +1 -1
  161. package/dist/schema-sql.js.map +1 -1
  162. package/dist/schema.d.ts +146 -0
  163. package/dist/schema.js +12 -0
  164. package/dist/schema.js.map +1 -1
  165. package/dist/session-commands.e2e.test.js +36 -11
  166. package/dist/session-commands.e2e.test.js.map +1 -1
  167. package/dist/session-events.d.ts +19 -7
  168. package/dist/session-events.js +166 -22
  169. package/dist/session-events.js.map +1 -1
  170. package/dist/session-ops.e2e.test.js +88 -11
  171. package/dist/session-ops.e2e.test.js.map +1 -1
  172. package/dist/sessions.d.ts +41 -18
  173. package/dist/sessions.js +174 -63
  174. package/dist/sessions.js.map +1 -1
  175. package/dist/shell.e2e.test.js +4 -4
  176. package/dist/slash-commands.js +12 -6
  177. package/dist/slash-commands.js.map +1 -1
  178. package/dist/slash-commands.test.js +2 -0
  179. package/dist/slash-commands.test.js.map +1 -1
  180. package/dist/sleep.e2e.test.js +29 -14
  181. package/dist/sleep.e2e.test.js.map +1 -1
  182. package/dist/sleeps.d.ts +1 -0
  183. package/dist/sleeps.js +21 -3
  184. package/dist/sleeps.js.map +1 -1
  185. package/dist/store.d.ts +1 -0
  186. package/dist/store.js +3 -2
  187. package/dist/store.js.map +1 -1
  188. package/dist/system-prompt.d.ts +3 -1
  189. package/dist/system-prompt.js +138 -64
  190. package/dist/system-prompt.js.map +1 -1
  191. package/dist/test/generate-log-plugin/index.js +15 -0
  192. package/dist/test/generate-log-plugin/index.js.map +1 -0
  193. package/dist/test/harness.d.ts +20 -1
  194. package/dist/test/harness.js +51 -10
  195. package/dist/test/harness.js.map +1 -1
  196. package/dist/test/replay.js +11 -7
  197. package/dist/test/replay.js.map +1 -1
  198. package/dist/thread-reducer.d.ts +29 -3
  199. package/dist/thread-reducer.fixtures.test.js +58 -51
  200. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  201. package/dist/thread-reducer.js +112 -25
  202. package/dist/thread-reducer.js.map +1 -1
  203. package/dist/thread-reducer.test.js +197 -24
  204. package/dist/thread-reducer.test.js.map +1 -1
  205. package/dist/voice-attachments.e2e.test.js +108 -18
  206. package/dist/voice-attachments.e2e.test.js.map +1 -1
  207. package/dist/voice-call.e2e.test.js +9 -9
  208. package/dist/voice-call.js +6 -6
  209. package/dist/voice-call.js.map +1 -1
  210. package/dist/voice-openai.e2e.test.js +59 -23
  211. package/dist/voice-openai.e2e.test.js.map +1 -1
  212. package/dist/voice.js +80 -24
  213. package/dist/voice.js.map +1 -1
  214. package/dist/working-directory.e2e.test.js +11 -11
  215. package/dist/working-directory.e2e.test.js.map +1 -1
  216. package/package.json +12 -6
  217. package/src/agent-ui.e2e.test.ts +27 -13
  218. package/src/agent-ui.ts +2 -1
  219. package/src/analytics.e2e.test.ts +1 -1
  220. package/src/autostart.ts +55 -10
  221. package/src/bot.ts +29 -5
  222. package/src/btw.e2e.test.ts +3 -3
  223. package/src/builtin-skills.test.ts +3 -4
  224. package/src/builtin-skills.ts +1 -1
  225. package/src/catalog-refresh.e2e.test.ts +2 -2
  226. package/src/cli/bot.ts +23 -14
  227. package/src/cli/discord.ts +30 -6
  228. package/src/cli/schedule.ts +23 -9
  229. package/src/cli/session.ts +35 -12
  230. package/src/cli/shared.ts +43 -8
  231. package/src/cli-discord.e2e.test.ts +13 -8
  232. package/src/cli-lock.e2e.test.ts +88 -20
  233. package/src/cli-read.e2e.test.ts +17 -8
  234. package/src/cli-sessions.e2e.test.ts +4 -3
  235. package/src/cli.ts +2 -0
  236. package/src/commands/preference-commands.ts +38 -4
  237. package/src/commands/session-commands.ts +12 -7
  238. package/src/commands/thread-commands.ts +92 -24
  239. package/src/commands/worktree-commands.ts +10 -9
  240. package/src/commands.e2e.test.ts +25 -21
  241. package/src/coordinator-agents.e2e.test.ts +168 -0
  242. package/src/coordinator-idle.e2e.test.ts +122 -0
  243. package/src/coordinator-queue.e2e.test.ts +279 -0
  244. package/src/coordinator-warmup.e2e.test.ts +158 -0
  245. package/src/coordinator.e2e.test.ts +545 -0
  246. package/src/coordinator.ts +168 -0
  247. package/src/db.test.ts +1 -0
  248. package/src/diagram.test.ts +56 -0
  249. package/src/diagram.ts +243 -0
  250. package/src/duration.ts +13 -5
  251. package/src/effects.ts +99 -27
  252. package/src/event-loop.ts +71 -42
  253. package/src/format-parts.ts +53 -22
  254. package/src/gateway-replay.e2e.test.ts +4 -4
  255. package/src/harness.e2e.test.ts +2 -2
  256. package/src/hello-thread.e2e.test.ts +59 -5
  257. package/src/ingress.ts +62 -12
  258. package/src/interaction-context.ts +12 -11
  259. package/src/interrupt.e2e.test.ts +8 -5
  260. package/src/legacy.e2e.test.ts +1 -1
  261. package/src/lock-routes.ts +62 -8
  262. package/src/lock-server.ts +23 -7
  263. package/src/main.ts +22 -7
  264. package/src/markdown/markdown.test.ts +4 -4
  265. package/src/markdown/render-markdown.ts +6 -5
  266. package/src/onboarding-cli.e2e.test.ts +7 -6
  267. package/src/onboarding.e2e.test.ts +8 -10
  268. package/src/onboarding.ts +13 -29
  269. package/src/permissions.e2e.test.ts +3 -3
  270. package/src/plugin/coordinator.ts +401 -0
  271. package/src/plugin/index.ts +68 -15
  272. package/src/plugin.e2e.test.ts +1 -1
  273. package/src/preference-commands.e2e.test.ts +103 -23
  274. package/src/prompt.ts +87 -43
  275. package/src/questions.e2e.test.ts +13 -13
  276. package/src/queue-sources.e2e.test.ts +197 -0
  277. package/src/queue.e2e.test.ts +48 -30
  278. package/src/queue.test.ts +59 -0
  279. package/src/queue.ts +104 -24
  280. package/src/remote-send.e2e.test.ts +6 -6
  281. package/src/remote-send.ts +2 -1
  282. package/src/renderer.e2e.test.ts +7 -7
  283. package/src/restart.e2e.test.ts +2 -2
  284. package/src/routines.e2e.test.ts +2 -2
  285. package/src/scheduler.e2e.test.ts +45 -19
  286. package/src/scheduler.ts +4 -2
  287. package/src/schema-sql.ts +1 -1
  288. package/src/schema.ts +13 -0
  289. package/src/session-commands.e2e.test.ts +38 -11
  290. package/src/session-events.ts +160 -21
  291. package/src/session-ops.e2e.test.ts +90 -10
  292. package/src/sessions.ts +214 -64
  293. package/src/shell.e2e.test.ts +4 -4
  294. package/src/slash-commands.test.ts +2 -0
  295. package/src/slash-commands.ts +12 -6
  296. package/src/sleep.e2e.test.ts +30 -13
  297. package/src/sleeps.ts +20 -3
  298. package/src/store.ts +5 -2
  299. package/src/system-prompt.ts +143 -63
  300. package/src/test/generate-log-plugin/index.ts +16 -0
  301. package/src/test/harness.ts +77 -14
  302. package/src/test/replay.ts +12 -6
  303. package/src/thread-reducer.fixtures.test.ts +58 -51
  304. package/src/thread-reducer.test.ts +207 -26
  305. package/src/thread-reducer.ts +138 -37
  306. package/src/voice-attachments.e2e.test.ts +110 -18
  307. package/src/voice-call.e2e.test.ts +9 -9
  308. package/src/voice-call.ts +6 -6
  309. package/src/voice-openai.e2e.test.ts +64 -24
  310. package/src/voice.ts +101 -26
  311. package/src/working-directory.e2e.test.ts +11 -11
  312. package/dist/message-tool.e2e.test.js +0 -39
  313. package/dist/message-tool.e2e.test.js.map +0 -1
  314. package/dist/plugin/read-media.d.ts +0 -23
  315. package/dist/plugin/read-media.js +0 -72
  316. package/dist/plugin/read-media.js.map +0 -1
  317. package/dist/plugin/session-id/index.js +0 -18
  318. package/dist/plugin/session-id/index.js.map +0 -1
  319. package/dist/probe-live.tmp.js +0 -82
  320. package/dist/probe-live.tmp.js.map +0 -1
  321. package/dist/tasks.e2e.test.js +0 -173
  322. package/dist/tasks.e2e.test.js.map +0 -1
  323. /package/dist/{message-tool.e2e.test.d.ts → coordinator-agents.e2e.test.d.ts} +0 -0
  324. /package/dist/{probe-live.tmp.d.ts → coordinator-idle.e2e.test.d.ts} +0 -0
  325. /package/dist/{tasks.e2e.test.d.ts → coordinator-queue.e2e.test.d.ts} +0 -0
  326. /package/dist/{plugin/session-id → test/generate-log-plugin}/index.d.ts +0 -0
package/src/sessions.ts CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path'
6
6
  import crypto from 'node:crypto'
7
7
  import { setTimeout as sleep } from 'node:timers/promises'
8
8
  import type { SessionMetadata } from '@opencode/client'
9
- import type { TextChannel, ThreadChannel } from 'discord.js'
9
+ import type { TextChannel } from 'discord.js'
10
10
  import * as orm from 'drizzle-orm'
11
11
 
12
12
  import {
@@ -14,6 +14,7 @@ import {
14
14
  globalModel,
15
15
  oc,
16
16
  parseModel,
17
+ projectChannelOf,
17
18
  projectOf,
18
19
  readMarker,
19
20
  rootSession,
@@ -22,6 +23,7 @@ import {
22
23
  textChannel,
23
24
  type Author,
24
25
  type Bot,
26
+ type Conversation,
25
27
  type ModelChoice,
26
28
  type PromptFile,
27
29
  } from './bot.ts'
@@ -29,7 +31,7 @@ import { ConfigError, DbError, DiscordError, OpenCodeError, OpenCodeUnavailableE
29
31
  import { SILENT_MESSAGE_FLAGS } from './format-parts.ts'
30
32
  import { createLogger } from './logger.ts'
31
33
  import type { OpenCodeClient, OpencodeEndpoint } from './opencode-server.ts'
32
- import { prompt, runInSession } from './prompt.ts'
34
+ import { prompt, runInSession, type Sender } from './prompt.ts'
33
35
  import { formatEcho } from './queue.ts'
34
36
  import type { Route } from './routes.ts'
35
37
  import * as schema from './schema.ts'
@@ -73,14 +75,14 @@ export function btwPrompt({
73
75
  ].join('\n')
74
76
  }
75
77
 
76
- // Project channel, project directory and current cwd of a session thread.
77
- export async function threadProject(bot: Bot, thread: ThreadChannel) {
78
- const channelId = thread.parentId
79
- if (!channelId) return new DiscordError({ operation: `find the channel of thread ${thread.id}` })
78
+ // Project channel, project directory and current cwd of a conversation's root session.
79
+ export async function conversationProject(bot: Bot, conversation: Conversation) {
80
+ const channelId = projectChannelOf(conversation)
81
+ if (!channelId) return new DiscordError({ operation: `find the channel of conversation ${conversation.id}` })
80
82
  const row = await projectOf(bot, channelId)
81
83
  if (row instanceof Error) return row
82
84
  if (!row) return new DiscordError({ operation: `find the project of channel ${channelId}` })
83
- const sessionId = rootSession(bot, thread.id)
85
+ const sessionId = rootSession(bot, conversation.id)
84
86
  if (sessionId instanceof Error) return sessionId
85
87
  const directory = await sessionDirectory(bot, sessionId)
86
88
  if (directory instanceof Error) return directory
@@ -89,8 +91,8 @@ export async function threadProject(bot: Bot, thread: ThreadChannel) {
89
91
 
90
92
  // /cwd and `kimaki session cwd`: show the cwd, or move the session to a
91
93
  // project subfolder or linked worktree.
92
- export async function sessionCwd(bot: Bot, { thread, directory }: { thread: ThreadChannel; directory?: string }) {
93
- const project = await threadProject(bot, thread)
94
+ export async function sessionCwd(bot: Bot, { conversation, directory }: { conversation: Conversation; directory?: string }) {
95
+ const project = await conversationProject(bot, conversation)
94
96
  if (project instanceof Error) return project
95
97
  if (!directory) return { directory: project.directory }
96
98
  const destination = await resolveWorkingDirectory({
@@ -100,7 +102,7 @@ export async function sessionCwd(bot: Bot, { thread, directory }: { thread: Thre
100
102
  if (destination instanceof Error) return destination
101
103
  const plugin = await bot.features.waitForPlugin(destination)
102
104
  if (plugin instanceof Error) return plugin
103
- const sessionId = rootSession(bot, thread.id)
105
+ const sessionId = rootSession(bot, conversation.id)
104
106
  if (sessionId instanceof Error) return sessionId
105
107
  const moved = await oc(bot, 'session.move', (client) => client.session.move({ sessionID: sessionId, directory: destination, delivery: 'steer' }))
106
108
  if (moved instanceof Error) return moved
@@ -128,8 +130,9 @@ export async function catalogReady(bot: Pick<Bot, 'opencode'>, directory: string
128
130
  if (ready instanceof Error) return ready
129
131
  }
130
132
 
131
- // Agents a session may switch to (not subagents, not hidden ones).
132
- export async function primaryAgents(bot: Bot, directory: string) {
133
+ // Agents a session may switch to (not subagents, not hidden ones). OpenCode lists
134
+ // its default agent (`default_agent`, else build) first: core/src/agent.ts `list`.
135
+ export async function primaryAgents(bot: Pick<Bot, 'opencode'>, directory: string) {
133
136
  const ready = await catalogReady(bot, directory)
134
137
  if (ready instanceof Error) return ready
135
138
  const agents = await oc(bot, 'agent.list', (client) => client.agent.list({ location: { directory } }))
@@ -137,6 +140,19 @@ export async function primaryAgents(bot: Bot, directory: string) {
137
140
  return agents.data.filter((agent) => agent.mode !== 'subagent' && !agent.hidden)
138
141
  }
139
142
 
143
+ // What a new session of the channel starts with: its project folder, and the channel agent or OpenCode's default agent.
144
+ export async function channelDefaults(bot: Pick<Bot, 'db' | 'opencode'>, channelId: string) {
145
+ const row = await bot.db.query.channel_directories
146
+ .findFirst({ where: { channel_id: channelId }, with: { channel_agent: true } })
147
+ .catch((cause) => new DbError({ operation: 'read channel defaults', cause }))
148
+ if (row instanceof Error) return row
149
+ if (!row) return null
150
+ if (row.channel_agent) return { projectDirectory: row.directory, defaultAgent: row.channel_agent.agent_name }
151
+ const agents = await primaryAgents(bot, row.directory)
152
+ if (agents instanceof Error) return agents
153
+ return { projectDirectory: row.directory, defaultAgent: agents[0]?.id ?? null }
154
+ }
155
+
140
156
  export async function agentSelection(bot: Bot, { directory, agent }: { directory: string; agent: string }) {
141
157
  const agents = await primaryAgents(bot, directory)
142
158
  if (agents instanceof Error) return agents
@@ -149,10 +165,29 @@ export async function agentSelection(bot: Bot, { directory, agent }: { directory
149
165
  // OpenCode switches the agent and model separately, including the configured variant.
150
166
  export async function switchAgent(
151
167
  bot: Bot,
152
- { sessionId, directory, agent, model }: { sessionId: string; directory: string; agent: string; model?: ModelChoice | null },
168
+ {
169
+ sessionId,
170
+ directory,
171
+ agent,
172
+ model,
173
+ reselect = false,
174
+ }: {
175
+ sessionId: string
176
+ directory: string
177
+ agent: string
178
+ model?: ModelChoice | null
179
+ // An explicit agent pick by the user (/agent, /<agent>-agent): apply the agent's model even when unchanged.
180
+ reselect?: boolean
181
+ },
153
182
  ) {
154
183
  const selected = await agentSelection(bot, { directory, agent })
155
184
  if (selected instanceof Error) return selected
185
+ // naming the session's current agent again (agents pass --agent <current_agent>
186
+ // on every send) keeps the model the session runs on, e.g. one set with /model.
187
+ if (!model && !reselect) {
188
+ const info = await oc(bot, 'session.get', (client) => client.session.get({ sessionID: sessionId }))
189
+ if (!(info instanceof Error) && info.agent === selected.agent) return { ...selected, model: null }
190
+ }
156
191
  const choice = model ?? selected.model
157
192
  if (choice) {
158
193
  const switched = await oc(bot, 'session.switchModel', (client) => client.session.switchModel({
@@ -171,7 +206,7 @@ async function putInstructions(
171
206
  bot: Bot,
172
207
  {
173
208
  sessionId,
174
- thread,
209
+ conversation,
175
210
  channel,
176
211
  directory,
177
212
  userId,
@@ -179,7 +214,7 @@ async function putInstructions(
179
214
  scheduledTask = null,
180
215
  }: {
181
216
  sessionId: string
182
- thread: ThreadChannel
217
+ conversation: Conversation
183
218
  channel: TextChannel
184
219
  directory: string
185
220
  userId: string
@@ -187,14 +222,14 @@ async function putInstructions(
187
222
  scheduledTask?: ScheduledRun | null
188
223
  },
189
224
  ): Promise<OpenCodeUnavailableError | OpenCodeError | void> {
190
- // The agent list is optional prompt context: a failure must not strand the thread.
225
+ // The agent list is optional prompt context: a failure must not strand the conversation.
191
226
  const found = await primaryAgents(bot, directory)
192
227
  if (found instanceof Error) logger.warn(`agent list for instructions failed`, found)
193
228
  // The ID is what switchAgent and session.create take.
194
229
  const agents = found instanceof Error ? [] : found.map((agent) => ({ name: agent.id, description: agent.description ?? '' }))
195
230
  const value = baseInstructions({
196
231
  sessionId,
197
- threadId: thread.id,
232
+ threadId: conversation.id,
198
233
  channelId: channel.id,
199
234
  guildId: channel.guildId,
200
235
  userId,
@@ -203,6 +238,8 @@ async function putInstructions(
203
238
  agents,
204
239
  parentSessionId,
205
240
  scheduledTask,
241
+ // Derived from where the session lives, never from bindings that can be stale.
242
+ coordinator: conversation.id === channel.id,
206
243
  disabled: bot.disabledPrompt,
207
244
  })
208
245
  const put = await oc(bot, 'instructions.entry.put', (client) =>
@@ -225,6 +262,7 @@ export function createPluginWait(bot: Pick<Bot, 'opencode'>): PluginWait {
225
262
  if (endpoint && active.get(endpoint)?.has(directory)) return
226
263
  const location = { directory }
227
264
  const deadline = Date.now() + 10_000
265
+ let loadError: string | undefined
228
266
  while (Date.now() < deadline) {
229
267
  const endpoint = bot.opencode.endpoint
230
268
  if (!endpoint) return new OpenCodeUnavailableError({ reason: 'not connected' })
@@ -240,8 +278,18 @@ export function createPluginWait(bot: Pick<Bot, 'opencode'>): PluginWait {
240
278
  active.set(endpoint, (active.get(endpoint) ?? new Set()).add(directory))
241
279
  return
242
280
  }
281
+ // A plugin that fails to load has no ID: find the shim directory by its path.
282
+ const failed = plugins.data.find((plugin) => plugin.source.type === 'local' && path.basename(plugin.source.path) === 'kimaki')
283
+ loadError = failed?.state.status === 'failed' ? (failed.state.ref ?? failed.state.error) : undefined
243
284
  await sleep(200)
244
285
  }
286
+ if (loadError) {
287
+ // TODO: remove the restart hint once OpenCode's Bun evicts failed ESM loads; Bun 1.4.4 keeps
288
+ // them forever, so a reload during `pnpm build` stays broken after the build ends.
289
+ return new ConfigError({
290
+ reason: `Kimaki plugin failed to load in OpenCode (${loadError}); the OpenCode log has the cause. If it names code that no longer exists, OpenCode kept a stale failed load: run \`opencode service restart\``,
291
+ })
292
+ }
245
293
  return new ConfigError({
246
294
  reason: `Kimaki plugin is not active in OpenCode for ${directory}. Kimaki writes it to <OpenCode config dir>/plugins/kimaki/ on start; check \`opencode plugin list\` and the OpenCode logs`,
247
295
  })
@@ -257,13 +305,13 @@ export async function ensureSessionMarker(
257
305
  bot: Bot,
258
306
  {
259
307
  sessionId,
260
- thread,
308
+ conversation,
261
309
  channelId,
262
310
  directory,
263
311
  userId,
264
312
  }: {
265
313
  sessionId: string
266
- thread: ThreadChannel
314
+ conversation: Conversation
267
315
  channelId: string
268
316
  directory: string
269
317
  userId: string
@@ -277,13 +325,13 @@ export async function ensureSessionMarker(
277
325
  if (marker && marker.dataDir === bot.dataDir && marker.lockPort === bot.lockPort) return
278
326
  // Instructions first: the marker means "set up", so a failure here is retried on the next input.
279
327
  if (!marker) {
280
- logger.log(`adopting legacy session ${sessionId} of thread ${thread.id}`)
328
+ logger.log(`adopting legacy session ${sessionId} of conversation ${conversation.id}`)
281
329
  const channel = await textChannel(bot, channelId)
282
330
  if (channel instanceof Error) return channel
283
- const instructions = await putInstructions(bot, { sessionId, thread, channel, directory, userId })
331
+ const instructions = await putInstructions(bot, { sessionId, conversation, channel, directory, userId })
284
332
  if (instructions instanceof Error) return instructions
285
333
  }
286
- const kimaki = { ...marker?.fields, source: 'discord', ...cliContext(bot), threadId: thread.id, channelId }
334
+ const kimaki = { ...marker?.fields, source: 'discord', ...cliContext(bot), threadId: conversation.id, channelId }
287
335
  const updated = await oc(bot, 'session.update', (client) =>
288
336
  client.session.update({ sessionID: sessionId, metadata: { ...info.metadata, kimaki } }),
289
337
  )
@@ -311,6 +359,30 @@ export async function refreshCliContext(bot: Bot) {
311
359
  }
312
360
  }
313
361
 
362
+ type ChannelDefaults = {
363
+ channel_model?: { model_id: string; variant: string | null } | null
364
+ channel_agent?: { agent_name: string } | null
365
+ }
366
+
367
+ // Agent and model of a new session: an agent the input asked for, else the
368
+ // channel agent; the model from the input, that agent, the channel, the global default.
369
+ async function agentAndModel(
370
+ bot: Bot,
371
+ { defaults, route, directory, explicitModel }: { defaults: ChannelDefaults | undefined; route: Route; directory: string; explicitModel?: ModelChoice },
372
+ ) {
373
+ const requestedAgent = (route.kind === 'steer' && route.agent) || defaults?.channel_agent?.agent_name
374
+ const selected = requestedAgent ? await agentSelection(bot, { directory, agent: requestedAgent }) : null
375
+ if (selected instanceof Error) return selected
376
+ const agentModel = route.kind === 'steer' && route.agent ? selected?.model : null
377
+ const global = await globalModel(bot)
378
+ if (global instanceof Error) return global
379
+ const choice = explicitModel ?? agentModel ?? parseModel(defaults?.channel_model?.model_id, defaults?.channel_model?.variant) ?? selected?.model ?? global
380
+ const model = choice
381
+ ? { providerID: choice.providerID, id: choice.id, ...(choice.variant && { variant: choice.variant }) }
382
+ : undefined
383
+ return { model, agent: selected?.agent }
384
+ }
385
+
314
386
  export async function startSession(
315
387
  bot: Bot,
316
388
  {
@@ -318,6 +390,7 @@ export async function startSession(
318
390
  directory: requestedDirectory,
319
391
  route,
320
392
  author,
393
+ members = [],
321
394
  messageId,
322
395
  startMessageId = messageId,
323
396
  showInput = startMessageId === null,
@@ -326,6 +399,7 @@ export async function startSession(
326
399
  model: explicitModel,
327
400
  permissions,
328
401
  parentSessionId,
402
+ sender,
329
403
  task,
330
404
  worktree,
331
405
  baseBranch,
@@ -336,6 +410,8 @@ export async function startSession(
336
410
  // The first input: a prompt, a shell command or an OpenCode command.
337
411
  route: Exclude<Route, { kind: 'btw' | 'new-session' | 'queue' }>
338
412
  author: Author
413
+ // More Discord users to add to the thread, next to the author (`kimaki send --user`).
414
+ members?: readonly string[]
339
415
  // The Discord message this input came from; it keys the prompt id.
340
416
  messageId: string
341
417
  // The thread starts from this message; null for a thread without one.
@@ -350,12 +426,13 @@ export async function startSession(
350
426
  model?: ModelChoice
351
427
  permissions?: PermissionRules
352
428
  parentSessionId?: string
429
+ sender?: Sender
353
430
  // A scheduled run (scheduler.ts): marks the session as started by this task.
354
431
  task?: ScheduledRun
355
432
  // undefined uses the channel default; false explicitly reuses an existing cwd.
356
433
  worktree?: string | false
357
434
  baseBranch?: string
358
- // false: only set up the thread (/new-worktree), no first input.
435
+ // false: only set up the thread (/new-worktree, /new-session without a prompt), no first input.
359
436
  run?: boolean
360
437
  },
361
438
  ): Promise<Error | { threadId: string; sessionId: string }> {
@@ -368,7 +445,7 @@ export async function startSession(
368
445
  const useWorktree = typeof worktree === 'string' || (worktree !== false && channelWorktrees)
369
446
  const textSlug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40).replace(/^-|-$/g, '') || 'session'
370
447
  const slug = typeof worktree === 'string' && worktree ? worktree : `${textSlug}-${crypto.randomBytes(4).toString('hex')}`
371
- const threadName = explicitName ?? (useWorktree ? `⬦ ${slug}` : text.replace(/\s+/g, ' ').slice(0, 80) || 'Kimaki session')
448
+ const threadName = explicitName ?? (useWorktree ? slug : text.replace(/\s+/g, ' ').slice(0, 80) || 'Kimaki session')
372
449
 
373
450
  // The thread shows in Discord while the checkout, plugin, agent and model are resolved.
374
451
  const creating = (async () => {
@@ -378,8 +455,11 @@ export async function startSession(
378
455
  .create({ name: threadName, autoArchiveDuration: 1440, ...(startMessageId && { startMessage: startMessageId }) })
379
456
  .catch((cause) => new DiscordError({ operation: 'create thread', cause }))
380
457
  if (thread instanceof Error) return thread
381
- // Discord only adds the thread creator (the bot); the user must be added to see it in the sidebar.
382
- if (author.id !== bot.discord.user?.id) await thread.members.add(author.id).catch((e: Error) => logger.warn(`add member`, e))
458
+ // Discord only adds the thread creator (the bot); users must be added to see it in the sidebar.
459
+ // Not awaited: the session does not need them, and each add is a Discord round trip.
460
+ for (const id of new Set([author.id, ...members])) {
461
+ if (id !== bot.discord.user?.id) void thread.members.add(id).catch((e: Error) => logger.warn(`add member ${id}`, e))
462
+ }
383
463
  return { channel, thread }
384
464
  })()
385
465
  const setup = await (async () => {
@@ -390,17 +470,9 @@ export async function startSession(
390
470
  const directory = checkout?.directory ?? requestedDirectory
391
471
  const plugin = await bot.features.waitForPlugin(directory)
392
472
  if (plugin instanceof Error) return plugin
393
- const requestedAgent = (route.kind === 'steer' && route.agent) || defaults?.channel_agent?.agent_name
394
- const selected = requestedAgent ? await agentSelection(bot, { directory, agent: requestedAgent }) : null
395
- if (selected instanceof Error) return selected
396
- const agentModel = route.kind === 'steer' && route.agent ? selected?.model : null
397
- const global = await globalModel(bot)
398
- if (global instanceof Error) return global
399
- const choice = explicitModel ?? agentModel ?? parseModel(defaults?.channel_model?.model_id, defaults?.channel_model?.variant) ?? selected?.model ?? global
400
- const model = choice
401
- ? { providerID: choice.providerID, id: choice.id, ...(choice.variant && { variant: choice.variant }) }
402
- : undefined
403
- return { checkout, directory, model, agent: selected?.agent }
473
+ const chosen = await agentAndModel(bot, { defaults, route, directory, explicitModel })
474
+ if (chosen instanceof Error) return chosen
475
+ return { checkout, directory, ...chosen }
404
476
  })()
405
477
  const created = await creating
406
478
  if (created instanceof Error) return created
@@ -416,6 +488,7 @@ export async function startSession(
416
488
  channelId,
417
489
  source: task ? 'task' : 'discord',
418
490
  ...(task && { taskId: task.id, task }),
491
+ ...(bot.warmupTimers && { warmup: bot.warmupTimers }),
419
492
  ...cliContext(bot),
420
493
  ...(parentSessionId && { parentSessionId }),
421
494
  }
@@ -434,7 +507,7 @@ export async function startSession(
434
507
 
435
508
  const instructions = await putInstructions(bot, {
436
509
  sessionId: session.id,
437
- thread,
510
+ conversation: thread,
438
511
  channel,
439
512
  directory,
440
513
  userId: author.id,
@@ -446,9 +519,10 @@ export async function startSession(
446
519
  const bound = await bindThread(bot, { threadId: thread.id, sessionId: session.id, channelId, directory, isNew: true })
447
520
  if (bound instanceof Error) return bound
448
521
  if (!run) {
522
+ const ready = checkout ? `Worktree ready: \`${directory}\`\nBranch: \`${checkout.branch ?? 'detached'}\`\n` : ''
449
523
  const posted = await thread
450
524
  .send({
451
- content: `Worktree ready: \`${directory}\`\nBranch: \`${checkout?.branch ?? 'detached'}\`\nSend a message to start working.`,
525
+ content: `${ready}Send a message to start working.`,
452
526
  allowedMentions: { parse: [] },
453
527
  flags: SILENT_MESSAGE_FLAGS,
454
528
  })
@@ -459,25 +533,88 @@ export async function startSession(
459
533
  if (showInput) {
460
534
  const names = files.map((file) => file.name).join(', ')
461
535
  const shown = names ? `${text}\nFiles: ${names}` : text
462
- const echo = await thread
463
- .send({ content: formatEcho({ username: author.username, text: shown }), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
464
- .catch((cause) => new DiscordError({ operation: 'send first input', cause }))
465
- if (echo instanceof Error) return echo
536
+ // Through the thread's effect queue, so the prompt does not wait for Discord,
537
+ // the echo comes before the session output and a long prompt is split.
538
+ bot.effects.run(thread.id, [{ type: 'send', text: formatEcho({ username: author.username, text: shown }), echo: true }])
466
539
  }
467
540
 
468
- const first = route.kind === 'steer' ? { kind: 'steer' as const, text: route.text } : route
541
+ const base = { sessionId: session.id, threadId: thread.id, threadName, author, messageId, files, sender }
542
+ // A new session has no run, question or permission to cancel: prompt directly instead of steer().
543
+ const sent = route.kind === 'steer'
544
+ ? await prompt(bot, { ...base, text: route.text, delivery: 'steer' })
545
+ : await runInSession(bot, { ...base, directory, route })
546
+ if (sent instanceof Error) return sent
547
+ return { threadId: thread.id, sessionId: session.id }
548
+ }
549
+
550
+ // The first message of a coordinator channel: one long session that lives in the
551
+ // channel itself, bound like a thread with the channel ID as its conversation.
552
+ export async function startCoordinator(
553
+ bot: Bot,
554
+ {
555
+ channel,
556
+ route,
557
+ author,
558
+ messageId,
559
+ files = [],
560
+ }: {
561
+ channel: TextChannel
562
+ route: Exclude<Route, { kind: 'btw' | 'new-session' | 'queue' }>
563
+ author: Author
564
+ messageId: string
565
+ files?: readonly PromptFile[]
566
+ },
567
+ ): Promise<Error | { threadId: string; sessionId: string }> {
568
+ const defaults = await bot.db.query.channel_directories
569
+ .findFirst({ where: { channel_id: channel.id }, with: { channel_model: true, channel_agent: true } })
570
+ .catch((cause) => new DbError({ operation: 'read channel defaults', cause }))
571
+ if (defaults instanceof Error) return defaults
572
+ if (!defaults) return new ConfigError({ reason: 'This channel is not configured with a project directory' })
573
+ const directory = defaults.directory
574
+ const plugin = await bot.features.waitForPlugin(directory)
575
+ if (plugin instanceof Error) return plugin
576
+ const chosen = await agentAndModel(bot, { defaults, route, directory })
577
+ if (chosen instanceof Error) return chosen
578
+ const title = `coordinator: ${channel.name}`
579
+ const session = await oc(bot, 'session.create', (client) =>
580
+ client.session.create({
581
+ title,
582
+ location: { directory },
583
+ ...(chosen.model && { model: chosen.model }),
584
+ ...(chosen.agent && { agent: chosen.agent }),
585
+ permissions: [NO_QUESTION_TOOL, ...disabledSkillRules(bot.disabledSkills)],
586
+ metadata: { kimaki: { threadId: channel.id, channelId: channel.id, source: 'discord', ...(bot.warmupTimers && { warmup: bot.warmupTimers }), ...cliContext(bot) } },
587
+ }),
588
+ )
589
+ if (session instanceof Error) return session
590
+ bot.analytics.track('session_created', { has_worktree: false, source: 'discord' })
591
+ const instructions = await putInstructions(bot, { sessionId: session.id, conversation: channel, channel, directory, userId: author.id })
592
+ if (instructions instanceof Error) return instructions
593
+ const bound = await bindThread(bot, { threadId: channel.id, sessionId: session.id, channelId: channel.id, directory, isNew: true })
594
+ if (bound instanceof Error) return bound
469
595
  const sent = await runInSession(bot, {
470
596
  sessionId: session.id,
471
- threadId: thread.id,
472
- threadName,
597
+ threadId: channel.id,
598
+ threadName: channel.name,
473
599
  directory,
474
- route: first,
600
+ route,
475
601
  author,
476
602
  messageId,
477
603
  files,
478
604
  })
479
605
  if (sent instanceof Error) return sent
480
- return { threadId: thread.id, sessionId: session.id }
606
+ return { threadId: channel.id, sessionId: session.id }
607
+ }
608
+
609
+ // /reset-coordinator: the channel forgets its coordinator. The next message starts a fresh
610
+ // one; the old session stays in OpenCode for /resume.
611
+ export async function endCoordinator(bot: Bot, channelId: string): Promise<DbError | void> {
612
+ const deleted = await bot.db
613
+ .delete(schema.thread_sessions)
614
+ .where(orm.eq(schema.thread_sessions.thread_id, channelId))
615
+ .catch((cause) => new DbError({ operation: 'delete coordinator binding', cause }))
616
+ if (deleted instanceof Error) return deleted
617
+ bot.eventLoop.unbind(channelId)
481
618
  }
482
619
 
483
620
  // --- Sessions that get a new thread: /resume, /fork, /fork-subagent, btw.
@@ -542,7 +679,7 @@ async function adoptSession(
542
679
  if (marked instanceof Error) return marked
543
680
  const instructions = await putInstructions(bot, {
544
681
  sessionId,
545
- thread,
682
+ conversation: thread,
546
683
  channel,
547
684
  directory: session.location.directory,
548
685
  userId: author.id,
@@ -643,7 +780,7 @@ export async function resume(bot: Bot, { channelId, sessionId, author }: { chann
643
780
  export async function fork(
644
781
  bot: Bot,
645
782
  {
646
- sourceThread,
783
+ source,
647
784
  sessionId,
648
785
  before,
649
786
  subagent,
@@ -651,7 +788,7 @@ export async function fork(
651
788
  author,
652
789
  worktree,
653
790
  }: {
654
- sourceThread: ThreadChannel
791
+ source: Conversation
655
792
  sessionId: string
656
793
  before?: string
657
794
  // Thread name; default is OpenCode's fork title.
@@ -663,7 +800,7 @@ export async function fork(
663
800
  worktree?: { name: string; baseBranch?: string }
664
801
  },
665
802
  ) {
666
- const root = rootSession(bot, sourceThread.id)
803
+ const root = rootSession(bot, source.id)
667
804
  if (root instanceof Error) return root
668
805
  if (sessionId !== root) {
669
806
  const info = await oc(bot, 'session.get', (client) => client.session.get({ sessionID: sessionId }))
@@ -672,7 +809,7 @@ export async function fork(
672
809
  return new ConfigError({ reason: "This session is not the thread's root or a direct subagent. Run /fork or /fork-subagent in the session's own thread." })
673
810
  }
674
811
  }
675
- const project = await threadProject(bot, sourceThread)
812
+ const project = await conversationProject(bot, source)
676
813
  if (project instanceof Error) return project
677
814
  const channel = await textChannel(bot, project.channelId)
678
815
  if (channel instanceof Error) return channel
@@ -710,13 +847,13 @@ export async function fork(
710
847
  }
711
848
  const intro = subagent
712
849
  ? `**Forked subagent session created!**\nAgent: \`${subagent.agent}\`\nTask: ${subagent.description || 'No description'}\nFrom: \`${sessionId}\`\nNew session: \`${forked.id}\``
713
- : `**Forked session created!**\nFrom: <#${sourceThread.id}> (\`${sessionId}\`)\nNew session: \`${forked.id}\``
850
+ : `**Forked session created!**\nFrom: <#${source.id}> (\`${sessionId}\`)\nNew session: \`${forked.id}\``
714
851
  return adoptSession(bot, {
715
852
  channel,
716
853
  session: relocated,
717
854
  discard,
718
855
  // OpenCode titles forks "<title> (fork #1)".
719
- threadName: name ?? (forked.title || `Fork: ${subagent?.description || sourceThread.name}`),
856
+ threadName: name ?? (forked.title || `Fork: ${subagent?.description || source.name}`),
720
857
  intro,
721
858
  note: 'You can now continue the conversation from this point.',
722
859
  author,
@@ -729,31 +866,36 @@ export async function fork(
729
866
  export async function forkBtw(
730
867
  bot: Bot,
731
868
  {
732
- sourceThread,
869
+ source,
733
870
  text,
734
871
  author,
735
872
  messageId,
736
873
  files = [],
737
874
  agent,
875
+ parentSessionId,
876
+ sender,
738
877
  }: {
739
- sourceThread: ThreadChannel
878
+ source: Conversation
740
879
  text: string
741
880
  author: Author
742
881
  messageId: string
743
882
  files?: readonly PromptFile[]
744
883
  // A voice message asked for this agent.
745
884
  agent?: string
885
+ // Explicit `kimaki send --parent-session`, recorded in the fork's marker.
886
+ parentSessionId?: string
887
+ sender?: Sender
746
888
  },
747
889
  ): Promise<ConfigError | OpenCodeUnavailableError | OpenCodeError | DiscordError | DbError | { threadId: string; sessionId: string }> {
748
- const parentSessionId = rootSession(bot, sourceThread.id)
749
- if (parentSessionId instanceof Error) return parentSessionId
750
- const project = await threadProject(bot, sourceThread)
890
+ const sourceSessionId = rootSession(bot, source.id)
891
+ if (sourceSessionId instanceof Error) return sourceSessionId
892
+ const project = await conversationProject(bot, source)
751
893
  if (project instanceof Error) return project
752
894
  const channel = await textChannel(bot, project.channelId)
753
895
  if (channel instanceof Error) return channel
754
896
 
755
897
  const [forked, thread] = await Promise.all([
756
- oc(bot, 'session.fork', (client) => client.session.fork({ sessionID: parentSessionId })),
898
+ oc(bot, 'session.fork', (client) => client.session.fork({ sessionID: sourceSessionId })),
757
899
  channel.threads
758
900
  .create({ name: `btw: ${text.replace(/\s+/g, ' ')}`.slice(0, 100), autoArchiveDuration: 1440 })
759
901
  .catch((cause) => new DiscordError({ operation: 'create btw thread', cause })),
@@ -769,9 +911,16 @@ export async function forkBtw(
769
911
  return thread
770
912
  }
771
913
 
772
- const kimaki = { threadId: thread.id, channelId: project.channelId, source: 'discord', ...cliContext(bot) }
914
+ const kimaki = { threadId: thread.id, channelId: project.channelId, source: 'discord', ...cliContext(bot), ...(parentSessionId && { parentSessionId }) }
915
+ const fromCoordinator = !source.isThread()
773
916
  const marked = await oc(bot, 'session.update', (client) => client.session.update({ sessionID: forked.id, metadata: { kimaki } }))
774
917
  if (marked instanceof Error) return marked
918
+ // A fork of a thread keeps its copied instructions (prompt cache); a fork of a coordinator gets thread
919
+ // ones. The plugin makes it a worker (plugin/coordinator.ts).
920
+ if (fromCoordinator) {
921
+ const instructions = await putInstructions(bot, { sessionId: forked.id, conversation: thread, channel, directory: project.directory, userId: author.id, parentSessionId: sourceSessionId })
922
+ if (instructions instanceof Error) return instructions
923
+ }
775
924
  const bound = await bindThread(bot, {
776
925
  threadId: thread.id,
777
926
  sessionId: forked.id,
@@ -782,7 +931,7 @@ export async function forkBtw(
782
931
  if (bound instanceof Error) return bound
783
932
  await thread.members.add(author.id).catch((e: Error) => logger.warn(`add btw member`, e))
784
933
  const intro = await thread
785
- .send({ content: `Reusing context from <#${sourceThread.id}> to answer prompt...\n${text}`.slice(0, 2_000), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
934
+ .send({ content: `Reusing context from <#${source.id}> to answer prompt...\n${text}`.slice(0, 2_000), allowedMentions: { parse: [] }, flags: SILENT_MESSAGE_FLAGS })
786
935
  .catch((cause) => new DiscordError({ operation: 'send btw intro', cause }))
787
936
  if (intro instanceof Error) return intro
788
937
  if (agent) {
@@ -794,11 +943,12 @@ export async function forkBtw(
794
943
  sessionId: forked.id,
795
944
  threadId: thread.id,
796
945
  threadName: thread.name,
797
- text: btwPrompt({ text, parentSessionId, sourceThreadId: sourceThread.id, sessionId: forked.id, threadId: thread.id }),
946
+ text: btwPrompt({ text, parentSessionId: sourceSessionId, sourceThreadId: source.id, sessionId: forked.id, threadId: thread.id }),
798
947
  author,
799
948
  messageId,
800
949
  delivery: 'steer',
801
950
  files,
951
+ sender,
802
952
  })
803
953
  if (sent instanceof Error) return sent
804
954
  return { threadId: thread.id, sessionId: forked.id }
@@ -95,12 +95,12 @@ test('!cmd while busy runs at once, does not interrupt, and its output reaches t
95
95
 
96
96
  slow-done
97
97
  ok
98
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
98
+ -# *main ⋅ Ns ⋅ deterministic-v2*
99
99
  --- from: user (tommy)
100
100
  What did it print? shell-check
101
101
  --- from: assistant (TestBot)
102
102
  I saw your shell output
103
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
103
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
104
104
  `)
105
105
  })
106
106
 
@@ -163,11 +163,11 @@ test('/command args runs an OpenCode command; unknown names are plain text', asy
163
163
  --- from: assistant (TestBot)
164
164
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
165
165
  reviewed
166
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
166
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
167
167
  --- from: user (tommy)
168
168
  /nope unknown-marker
169
169
  --- from: assistant (TestBot)
170
170
  plain text
171
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
171
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
172
172
  `)
173
173
  })
@@ -47,6 +47,7 @@ test('catalog names are sanitized, keep their suffix, skip collisions and MCP pr
47
47
  "fork-subagent",
48
48
  "btw",
49
49
  "abort",
50
+ "reset-coordinator",
50
51
  "queue",
51
52
  "clear-queue",
52
53
  "queue-command",
@@ -60,6 +61,7 @@ test('catalog names are sanitized, keep their suffix, skip collisions and MCP pr
60
61
  "model",
61
62
  "model-variant",
62
63
  "verbosity",
64
+ "toggle-coordinator",
63
65
  "routines",
64
66
  "command",
65
67
  "skill",