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/cli/bot.ts CHANGED
@@ -10,7 +10,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
10
10
  import dedent from 'string-dedent'
11
11
  import { wrapJsonSchema, type Goke } from 'goke'
12
12
 
13
- import { distCliEntry, writeAutostartScript } from '../autostart.ts'
13
+ import { distCliEntry, removeAutostartScript, stableNodePath, writeAutostartScript } from '../autostart.ts'
14
14
  import { parseDisabledPromptSections, parseDisabledSkills, PROMPT_SECTIONS } from '../builtin-skills.ts'
15
15
  import { callBot, DEFAULT_LOCK_PORT, RESTART_EXIT_CODE, startLockServer } from '../lock-server.ts'
16
16
  import { parseDuration } from '../duration.ts'
@@ -59,8 +59,10 @@ async function supervise(): Promise<never> {
59
59
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
60
60
  process.on(signal, () => state.child?.kill('SIGTERM'))
61
61
  }
62
+ // Resolved before any Node upgrade: `kimaki restart` must not spawn the deleted binary.
63
+ const node = stableNodePath()
62
64
  while (true) {
63
- const child = spawn(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
65
+ const child = spawn(node, [...process.execArgv, ...process.argv.slice(1)], {
64
66
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
65
67
  env: { ...process.env, KIMAKI_SUPERVISED: '1' },
66
68
  })
@@ -93,6 +95,8 @@ export function registerStartCommand(cli: Goke) {
93
95
  .option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
94
96
  .option('--disable-prompt <section>', wrapJsonSchema<string[]>({ type: 'array', items: { type: 'string' }, description: `System prompt section to remove from new sessions (repeatable): ${PROMPT_SECTIONS.join(', ')}` }))
95
97
  .option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
98
+ .option('--coordinator [enabled]', wrapJsonSchema<boolean>({ type: 'boolean', default: true, description: 'Channel messages go to one long coordinator session that starts threads for the work. "false" turns it off (/toggle-coordinator sets one channel)' }))
99
+ .option('--no-autostart', 'Do not open a terminal with the bot when OpenCode starts (for bots run by launchd, systemd or pm2)')
96
100
  .option('--if-not-running', 'Start only if no bot runs; never stop the running one (used by autostart)')
97
101
  .action(async (options) => {
98
102
  // The IPC check ignores a KIMAKI_SUPERVISED leaked into a shell by an older bot.
@@ -103,7 +107,7 @@ export function registerStartCommand(cli: Goke) {
103
107
  // Bot code loads only here: the other subcommands start without it.
104
108
  const [
105
109
  { startBot },
106
- { ensureOpencode, kimakiShellCommand, runOnboarding, startCaffeinate },
110
+ { ensureOpencode, runOnboarding, startCaffeinate },
107
111
  { emitEvent, gatewayCredentials, gatewayUrlsFromEnv, installUrlFor, readSavedCredentials, resolveCredentials, restApiUrl },
108
112
  { openDb },
109
113
  { createAnalytics },
@@ -201,14 +205,13 @@ export function registerStartCommand(cli: Goke) {
201
205
  // The agent calls this same install. Prefer the built dist entry, so agent
202
206
  // commands skip the tsx loader also when the bot runs from src.
203
207
  const distCli = distCliEntry()
204
- const kimaki = kimakiShellCommand({
205
- command: fs.existsSync(distCli)
206
- ? [process.execPath, distCli]
207
- : [process.execPath, ...process.execArgv, process.argv[1] ?? 'kimaki'],
208
- })
208
+ const node = stableNodePath()
209
+ const kimakiArgv = fs.existsSync(distCli)
210
+ ? [node, distCli]
211
+ : [node, ...process.execArgv, process.argv[1] ?? 'kimaki']
209
212
 
210
213
  const bot = await startBot({
211
- kimakiCommand: kimaki,
214
+ kimakiArgv,
212
215
  dataDir,
213
216
  token: credentials.token,
214
217
  appId: credentials.appId,
@@ -223,6 +226,7 @@ export function registerStartCommand(cli: Goke) {
223
226
  allowAllUsers: Boolean(options.allowAllUsers),
224
227
  disabledSkills,
225
228
  disabledPrompt,
229
+ coordinator: options.coordinator,
226
230
  })
227
231
  if (bot instanceof Error) failStartup(bot)
228
232
  // exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
@@ -235,25 +239,30 @@ export function registerStartCommand(cli: Goke) {
235
239
  process.once('disconnect', shutdown)
236
240
 
237
241
  const gateway = credentials.mode === 'gateway'
238
- const onboarded = await runOnboarding({ bot, dataDir, guildId: options.guild ?? install?.guildId, installUrl, kimaki, gateway, installerId: install?.installerId, machine })
242
+ const onboarded = await runOnboarding({ bot, dataDir, guildId: options.guild ?? install?.guildId, installUrl, gateway, installerId: install?.installerId, machine })
239
243
  // The bot keeps running; the next start retries onboarding.
240
244
  if (onboarded instanceof Error) {
241
245
  logger.error(`onboarding failed`, onboarded)
242
246
  if (!process.stdin.isTTY) emitEvent({ type: 'error', message: `Onboarding failed: ${onboarded.message}. The bot is running; restart kimaki to retry.` })
243
247
  return
244
248
  }
245
- if (onboarded) process.stderr.write(`Onboarding thread: https://discord.com/channels/${onboarded.guildId}/${onboarded.threadId}\n`)
249
+ if (onboarded) process.stderr.write(`Onboarding: https://discord.com/channels/${onboarded.guildId}/${onboarded.threadId}\n`)
246
250
  const voiceChannels = await ensureVoiceChannels(bot, { machine })
247
251
  if (voiceChannels instanceof Error) logger.warn('cannot create the Kimaki voice channel', voiceChannels)
248
- // Only the default port: the plugin checks that one. Runtime flags only;
249
- // credentials, onboarding and --voice are saved in SQLite.
250
- if (lock.port === DEFAULT_LOCK_PORT) {
252
+ // --no-autostart removes the script on every start, so the opt-out sticks while
253
+ // launchd or systemd passes the flag. Only the default port: the plugin checks
254
+ // that one. Runtime flags only; credentials, onboarding and --voice are saved in SQLite.
255
+ if (options.noAutostart) {
256
+ const removed = await removeAutostartScript({ dataDir })
257
+ if (removed instanceof Error) logger.warn('cannot remove the autostart script', removed)
258
+ } else if (lock.port === DEFAULT_LOCK_PORT) {
251
259
  const autostart = await writeAutostartScript({ dataDir, args: [
252
260
  ...(options.worktrees ? ['--worktrees'] : []),
253
261
  ...(options.allowAllUsers ? ['--allow-all-users'] : []),
254
262
  ...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
255
263
  ...disabledPrompt.flatMap((section) => ['--disable-prompt', section]),
256
264
  ...(options.noAnalytics ? ['--no-analytics'] : []),
265
+ ...(options.coordinator ? [] : ['--coordinator', 'false']),
257
266
  ...(options.machineName ? ['--machine-name', options.machineName] : []),
258
267
  ] })
259
268
  if (autostart instanceof Error) logger.warn('cannot write the autostart script', autostart)
@@ -1,6 +1,8 @@
1
1
  // Discord-facing commands: agent UI (buttons, upload requests) through the
2
2
  // running bot, plus thread/user lookups and uploads with the saved bot token.
3
3
 
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
4
6
  import path from 'node:path'
5
7
  import { ChannelType } from 'discord-api-types/v10'
6
8
  import { wrapJsonSchema, type Goke } from 'goke'
@@ -15,22 +17,23 @@ function agentShell() {
15
17
  return { fromShell: Boolean(process.env['OPENCODE_SESSION_ID']), toolCall: process.env['KIMAKI_TOOL_CALL'] }
16
18
  }
17
19
 
18
- // The model reads this after `kimaki buttons`: a reminder to explain the buttons if it did not.
20
+ // The model reads this after `kimaki buttons`. Text planned before a tool call
21
+ // is sometimes never written (#256), so it asks for a missing answer or explanation.
19
22
  const BUTTONS_SHOWN = dedent`
20
23
 
21
24
  Buttons shown in Discord.
22
25
 
23
- NB: never show only buttons with short labels.
24
- The user cannot see tool calls, command outputs, or subagent and task results. They only see your text.
25
- Check the text you wrote before this call. It must explain:
26
+ The user cannot see tool calls, command outputs, your reasoning, or subagent and task results. They only see your text.
27
+ If this turn has no text yet that answers the user and explains the buttons, write it now:
28
+ - your answer to what the user asked, if anything
26
29
  - what was done in this session, concisely, including any findings from tool or task outputs that the choice depends on
27
30
  - what each button does and its tradeoffs
28
- If anything is missing, write it now. Be concise: a few short lines, no walls of text. Do not only repeat the button labels. Then stop and wait for the click.
31
+ Be concise: a few short lines in plain sentences, no walls of text. Do not only repeat the button labels. Then stop and wait for the click.
29
32
 
30
33
  `
31
34
  // buttons, upload-request
32
35
  export function registerAgentUiCommands(cli: Goke) {
33
- cli.command('buttons', 'Show 1-3 action buttons. Call last, after visible text')
36
+ cli.command('buttons', 'Show 1-3 action buttons. Explain them in your text')
34
37
  .option('--data-dir <path>', DATA_DIR_HELP)
35
38
  .option('-s, --session <id>', SESSION_HELP)
36
39
  .option('-b, --button <spec>', wrapJsonSchema<string[]>({ type: 'array', items: { type: 'string' }, description: "Repeatable: Label[:white|blue|green|red]" }))
@@ -113,4 +116,25 @@ export function registerDiscordCommands(cli: Goke) {
113
116
  // Waits for Discord: big files and a busy thread take longer than the 30s default.
114
117
  await action({ route: 'upload', dataDir: options.dataDir, input, signal: AbortSignal.timeout(5 * 60_000) })
115
118
  })
119
+
120
+ cli.command('diagram <input>', 'Render a Mermaid diagram (file path, or - for stdin) to a light themed PNG. Upload it with upload-to-discord')
121
+ .option('-o, --output <path>', 'PNG file to write (default: input path with .png, or a temp file for stdin)')
122
+ .option('--dark', 'Dark theme instead of light')
123
+ .action(async (input, options) => {
124
+ const source = input === '-' ? await readStdin() : await fs.promises.readFile(input, 'utf8').catch((error: Error) => error)
125
+ if (source instanceof Error) fail(source)
126
+ const { renderDiagram } = await import('../diagram.ts')
127
+ const png = renderDiagram(source, { theme: options.dark ? 'dark' : 'light' })
128
+ if (png instanceof Error) fail(png)
129
+ const output = path.resolve(options.output ?? (input === '-' ? path.join(os.tmpdir(), `kimaki-diagram-${Date.now()}.png`) : input.replace(/(\.\w+)?$/, '.png')))
130
+ const written = await fs.promises.writeFile(output, png).catch((error: Error) => error)
131
+ if (written instanceof Error) fail(written)
132
+ process.stdout.write(`${output}\n`)
133
+ })
134
+ }
135
+
136
+ async function readStdin(): Promise<string> {
137
+ const chunks: Buffer[] = []
138
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk))
139
+ return Buffer.concat(chunks).toString('utf8')
116
140
  }
@@ -7,7 +7,10 @@ import { wrapJsonSchema, type Goke } from 'goke'
7
7
 
8
8
  import { BotNotRunningError } from '../errors.ts'
9
9
  import { callBot } from '../lock-server.ts'
10
- import { action, DATA_DIR_HELP, dataDirOrDefault, discordApi, fail, openCliDb, parseTarget, printJson, readClient, SESSION_HELP, targetOrEnv, waitAndPrintSession } from './shared.ts'
10
+ import { action, DATA_DIR_HELP, dataDirOrDefault, discordApi, fail, openCliDb, parseTarget, printJson, readClient, SESSION_HELP, targetOrEnv, waitAndPrintSessions } from './shared.ts'
11
+
12
+ // Bare `--wait`: long enough that the shell timeout ends it first.
13
+ const DEFAULT_WAIT_SECONDS = 24 * 60 * 60
11
14
 
12
15
  export function registerSendCommand(cli: Goke) {
13
16
  cli.command('send', 'Start a session in a channel, or continue a thread')
@@ -16,7 +19,7 @@ export function registerSendCommand(cli: Goke) {
16
19
  .option('-d, --project <path>', 'Project directory of the new thread')
17
20
  .option('--thread <id>', 'Continue this thread (ID or URL)')
18
21
  .option('-s, --session <id>', 'Continue this session: session ID, thread ID or thread URL')
19
- .option('-p, --prompt <text>', 'Prompt; thread suffixes . queue and . btw are supported')
22
+ .option('-p, --prompt <text>', 'Prompt; thread suffixes . queue and . btw are supported').required()
20
23
  .option('-f, --file <path>', wrapJsonSchema<string[]>({ type: 'array', items: { type: 'string' }, description: 'Attach a local file (repeatable)' }))
21
24
  .option('-n, --name <text>', 'Thread name')
22
25
  .option('--agent <name>', 'Agent ID')
@@ -28,12 +31,14 @@ export function registerSendCommand(cli: Goke) {
28
31
  .option('--parent-session <id>', 'Record the parent session in session metadata')
29
32
  .option('--permission <rule>', wrapJsonSchema<string[]>({ type: 'array', items: { type: 'string' }, description: 'Repeatable: tool[:pattern]:allow|deny|ask' }))
30
33
  .option('--notify-only', 'Post a notification thread without a model turn')
31
- .option('--wait', 'Wait until idle or input is needed, then print the session')
34
+ .option('--wait [seconds]', 'Wait until idle or input is needed (at most this many seconds, default 1 day), then print the session')
32
35
  .option('--send-at <when>', 'Schedule: UTC ISO date ending in Z, or cron expression (UTC)')
33
36
  .option('--pre-run <command>', 'Scheduled only: run first in the project. Exit 0 starts, stdout is appended')
34
37
  .option('--allow-concurrency', 'Scheduled only: allow overlapping runs of this routine')
35
38
  .action(async (options) => {
36
- if (options.wait && options.sendAt) fail(new Error('--wait cannot be used with --send-at: the routine runs later'))
39
+ const wait = options.wait === undefined ? undefined : options.wait === '' ? DEFAULT_WAIT_SECONDS : Number(options.wait)
40
+ if (wait !== undefined && !(Number.isFinite(wait) && wait > 0 && wait <= DEFAULT_WAIT_SECONDS)) fail(new Error('--wait takes a positive number of seconds, at most 86400'))
41
+ if (wait !== undefined && options.sendAt) fail(new Error('--wait cannot be used with --send-at: the routine runs later'))
37
42
  if (options.thread !== undefined && options.session !== undefined) fail(new Error('Use exactly one of --channel, --thread, --session, --project'))
38
43
  const continued = options.thread ?? options.session
39
44
  const target = continued === undefined ? {} : parseTarget(continued)
@@ -49,11 +54,14 @@ export function registerSendCommand(cli: Goke) {
49
54
  dataDir: dataDirOrDefault(options.dataDir),
50
55
  route: 'send',
51
56
  signal: AbortSignal.timeout(25 * 60_000),
52
- input: { ...input, sendAt: options.sendAt, preRun: options.preRun, allowConcurrency: options.allowConcurrency },
57
+ input: {
58
+ ...input, wait, sendAt: options.sendAt, preRun: options.preRun, allowConcurrency: options.allowConcurrency,
59
+ senderSessionId: process.env['OPENCODE_SESSION_ID'] || undefined,
60
+ },
53
61
  })
54
62
  if (result instanceof BotNotRunningError) {
55
63
  // No local bot (CI, another machine): the bot that owns the channel runs it (remote-send.ts).
56
- if (options.sendAt || options.wait) fail(new Error('Kimaki bot is not running here. --send-at and --wait need a local bot.'))
64
+ if (options.sendAt || wait !== undefined) fail(new Error('Kimaki bot is not running here. --send-at and --wait need a local bot.'))
57
65
  const [{ sendInput }, { sendWithoutBot }] = await Promise.all([import('../lock-routes.ts'), import('../remote-send.ts')])
58
66
  const parsed = sendInput.safeParse(input)
59
67
  if (!parsed.success) fail(new Error(parsed.error.issues.map((issue) => issue.message).join('; ')))
@@ -65,10 +73,16 @@ export function registerSendCommand(cli: Goke) {
65
73
  }
66
74
  if (result instanceof Error) fail(result)
67
75
  process.stdout.write(`${JSON.stringify(result.data)}\n`)
68
- if (!options.wait) return
76
+ if (wait === undefined) return
69
77
  const data = result.data
70
78
  if (!data || typeof data !== 'object' || !('sessionId' in data) || typeof data.sessionId !== 'string') fail(new Error('--wait requires an AI session, not --notify-only'))
71
- await waitAndPrintSession({ client: await readClient(), sessionId: data.sessionId })
79
+ await waitAndPrintSessions({
80
+ client: await readClient(),
81
+ sessionIds: [data.sessionId],
82
+ dataDir: options.dataDir,
83
+ signal: AbortSignal.timeout(wait * 1000),
84
+ timeout: `${wait}s`,
85
+ })
72
86
  })
73
87
  }
74
88
 
@@ -134,7 +148,7 @@ export function registerScheduleCommands(cli: Goke) {
134
148
  }
135
149
  }
136
150
 
137
- cli.command('sleep', 'Wake this session later with a new message in the same thread. Run it last, after your text. To monitor slow events (email replies, PR reviews), wait 2h or more; never poll every few minutes')
151
+ cli.command('sleep', 'Wake this session later with a new message in the same thread. Run it last, after your text. To monitor slow events (email replies, PR reviews), wait 2h or more; never poll every few minutes. When blocked on a user action outside the chat (a sudo or permission dialog, a login), ask for it, assume they will do it, and sleep 5m, then check and continue on your own')
138
152
  .option('--data-dir <path>', DATA_DIR_HELP)
139
153
  .option('--duration <duration>', 'Relative wait, e.g. 30m, 2h, 1d')
140
154
  .option('--until <date>', 'UTC ISO date ending in Z')
@@ -26,7 +26,7 @@ import {
26
26
  SESSION_HELP,
27
27
  resolveTarget,
28
28
  targetOrEnv,
29
- waitAndPrintSession,
29
+ waitAndPrintSessions,
30
30
  } from './shared.ts'
31
31
 
32
32
  const execFileAsync = promisify(execFile)
@@ -44,8 +44,11 @@ export function registerSessionQueryCommands(cli: Goke) {
44
44
  const errorCode = options.active ? 64 : 1
45
45
  const client = await readClient(errorCode)
46
46
  const directory = options.all ? undefined : await projectDirectory({ project: options.project, channel: undefined, dataDir: options.dataDir })
47
+ // By project ID, not directory: sessions in linked worktrees of the same clone belong to the project too.
48
+ const location = directory ? await client.location.get({ location: { directory } }).catch((error: Error) => error) : undefined
49
+ if (location instanceof Error) fail(location, errorCode)
47
50
  const [sessions, active, forms, permissions] = await Promise.all([
48
- allSessions({ client, directory }),
51
+ allSessions({ client, project: location?.project.id }),
49
52
  client.session.active().catch((error: Error) => error),
50
53
  client.form.list().catch((error: Error) => error),
51
54
  client.permission.request.list().catch((error: Error) => error),
@@ -82,15 +85,30 @@ export function registerSessionQueryCommands(cli: Goke) {
82
85
  process.stdout.write(`${transcriptsDir({ dataDir })}: ${result.written} written, ${result.skipped} skipped, ${result.failed} failed\n`)
83
86
  })
84
87
 
85
- cli.command('session wait <id>', 'Wait until idle or waiting for input, then print the session')
88
+ cli.command('session wait [...ids]', 'Wait until every session is idle or waits for the user, then print each one and why it returned')
89
+ .example('kimaki session wait ses_abc 1495130000000000000')
90
+ .example(`kimaki session wait $(kimaki session list --all --active | awk '{print $1}')`)
86
91
  .option('--data-dir <path>', DATA_DIR_HELP)
87
- .option('--timeout <duration>', 'Timeout, for example 30m or 2h')
88
- .action(async (id, options) => {
89
- const { sessionId } = await resolveTarget(id, options.dataDir)
92
+ .option('--timeout <duration>', 'Timeout, for example 30m or 2h; exit 1 when a session still runs')
93
+ .action(async (ids, options) => {
90
94
  const timeout = options.timeout ? parseDuration(options.timeout) : undefined
91
95
  if (timeout instanceof Error) fail(new Error('Use --timeout 30m, 2h, or another positive duration'))
96
+ // Empty when `$(kimaki session list --active)` found nothing.
97
+ if (ids.length === 0) {
98
+ process.stdout.write('No sessions to wait for\n')
99
+ return
100
+ }
101
+ const sessionIds: string[] = []
102
+ for (const id of ids) sessionIds.push((await resolveTarget(id, options.dataDir)).sessionId)
92
103
  const client = await readClient()
93
- await waitAndPrintSession({ client, sessionId, signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout) })
104
+ const done = await waitAndPrintSessions({
105
+ client,
106
+ sessionIds: [...new Set(sessionIds)],
107
+ dataDir: options.dataDir,
108
+ signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
109
+ timeout: options.timeout ?? '',
110
+ })
111
+ if (!done) process.exitCode = 1
94
112
  })
95
113
 
96
114
  cli.command('session editors <file>', 'List sessions that last edited a file, newest first')
@@ -147,7 +165,7 @@ export function registerSessionQueryCommands(cli: Goke) {
147
165
  })
148
166
  }
149
167
 
150
- // abort, archive, title, queue, command, shell, btw, fork, resume: all through the bot.
168
+ // abort, archive, compact, title, queue, command, shell, btw, fork, resume: all through the bot.
151
169
  export function registerSessionActionCommands(cli: Goke) {
152
170
  cli.command('session abort [id]', 'Stop the running turn and clear its queue')
153
171
  .option('--data-dir <path>', DATA_DIR_HELP)
@@ -157,6 +175,10 @@ export function registerSessionActionCommands(cli: Goke) {
157
175
  .option('--data-dir <path>', DATA_DIR_HELP)
158
176
  .action(async (id, options) => action({ route: 'session.archive', dataDir: options.dataDir, input: targetOrEnv(id) }))
159
177
 
178
+ cli.command('session compact [id]', 'Summarize the session history after its current turn (default: this session)')
179
+ .option('--data-dir <path>', DATA_DIR_HELP)
180
+ .action(async (id, options) => action({ route: 'session.compact', dataDir: options.dataDir, input: targetOrEnv(id) }))
181
+
160
182
  cli.command('session title <title>', 'Rename the session and its Discord thread')
161
183
  .option('--data-dir <path>', DATA_DIR_HELP)
162
184
  .option('-s, --session <id>', SESSION_HELP)
@@ -224,10 +246,11 @@ export function registerSessionHistoryCommands(cli: Goke) {
224
246
  await pipeline(fs.createReadStream(file), process.stdout)
225
247
  })
226
248
 
227
- cli.command('session read <id>', 'Print the messages of a session from OpenCode as markdown')
249
+ cli.command('session read <id>', 'Print the prompts and final answers of a session as markdown')
228
250
  .option('--data-dir <path>', DATA_DIR_HELP)
229
- .option('--thinking', 'Include reasoning')
230
- .option('--verbose', 'Include full tool inputs and outputs')
251
+ .option('--full', 'Every message with tool calls')
252
+ .option('--thinking', 'Full output with reasoning')
253
+ .option('--verbose', 'Full output with tool inputs and outputs')
231
254
  .option('--json', 'Print raw OpenCode messages')
232
255
  .action(async (id, options) => {
233
256
  // Subagent sessions are not in SQLite: resolveTarget keeps their ID.
@@ -238,7 +261,7 @@ export function registerSessionHistoryCommands(cli: Goke) {
238
261
  if (messages instanceof Error) fail(messages)
239
262
  return printJson(messages)
240
263
  }
241
- const markdown = await readSessionMarkdown({ client, sessionId, thinking: options.thinking, verbose: options.verbose })
264
+ const markdown = await readSessionMarkdown({ client, sessionId, full: options.full, thinking: options.thinking, verbose: options.verbose })
242
265
  if (markdown instanceof Error) fail(markdown)
243
266
  process.stdout.write(`${markdown}\n`)
244
267
  })
package/src/cli/shared.ts CHANGED
@@ -10,12 +10,12 @@ import path from 'node:path'
10
10
  import type { OpenCodeClient } from '@opencode/client'
11
11
  import { ChannelType } from 'discord-api-types/v10'
12
12
 
13
- import { DbError } from '../errors.ts'
13
+ import { BotNotRunningError, DbError } from '../errors.ts'
14
14
  import { canonicalPath } from '../file-edit-log.ts'
15
15
  import type { LockRouteInput, LockRouteName } from '../lock-routes.ts'
16
16
  import { callBot } from '../lock-server.ts'
17
17
  import { resolveOpencode } from '../opencode-server.ts'
18
- import { readSessionMarkdown, resolveSession, waitForSessionReady } from '../session-events.ts'
18
+ import { readSessionMarkdown, resolveSession, waitForSessions, type WaitStatus } from '../session-events.ts'
19
19
 
20
20
  export const DATA_DIR_HELP = 'Data directory (default: ~/.kimaki)'
21
21
  export const SESSION_HELP = 'Session ID, Discord thread ID or thread URL (default: OPENCODE_SESSION_ID)'
@@ -90,12 +90,47 @@ export async function resolveTarget(value: string, dataDir: string | undefined):
90
90
  fail(result)
91
91
  }
92
92
 
93
- export async function waitAndPrintSession({ client, sessionId, signal }: { client: OpenCodeClient; sessionId: string; signal?: AbortSignal }) {
94
- const waited = await waitForSessionReady({ client, sessionId, signal })
95
- if (waited instanceof Error) fail(waited)
96
- const markdown = await readSessionMarkdown({ client, sessionId })
97
- if (markdown instanceof Error) fail(markdown)
98
- process.stdout.write(`${markdown}\n`)
93
+ function statusText({ sessionId, status, buttons, timeout }: { sessionId: string; status: WaitStatus; buttons: string[]; timeout: string }): string {
94
+ // `kimaki buttons` ends the turn, so the run looks finished while the session waits for a click.
95
+ if (buttons.length > 0 && status !== 'running' && status !== 'question' && status !== 'permission') {
96
+ return `is paused: it showed buttons (${buttons.join(', ')}) and waits for the user to click one, so its work may not be done`
97
+ }
98
+ if (status === 'finished') return 'finished its run'
99
+ if (status === 'failed') return 'ended its run with an error'
100
+ if (status === 'interrupted') return 'was interrupted, by an abort or by a new user message that started another run'
101
+ if (status === 'idle') return 'was already idle: its last run ended before the wait started'
102
+ if (status === 'question') return 'is paused: it waits for the user to answer a question, so its work is not done'
103
+ if (status === 'permission') return 'is paused: it waits for the user to allow a permission, so its work is not done'
104
+ return `is still running after ${timeout}. Run kimaki session wait ${sessionId} to wait for the rest`
105
+ }
106
+
107
+ // Waits until every session ended its run or paused for the user, then prints each one in
108
+ // order under a header that says why it returned. Returns false when `signal` ended the wait first.
109
+ export async function waitAndPrintSessions({ client, sessionIds, dataDir, signal, timeout }: {
110
+ client: OpenCodeClient
111
+ sessionIds: readonly string[]
112
+ dataDir: string | undefined
113
+ signal?: AbortSignal
114
+ timeout: string
115
+ }): Promise<boolean> {
116
+ const statuses = await waitForSessions({ client, sessionIds, signal })
117
+ if (statuses instanceof Error) fail(statuses)
118
+ // Buttons live in the bot's memory. Without a bot nothing can show them.
119
+ const buttons = await callBot({ dataDir: dataDirOrDefault(dataDir), route: 'session.buttons', input: { sessionIds: [...sessionIds] } })
120
+ if (buttons instanceof Error && !(buttons instanceof BotNotRunningError)) process.stderr.write(`cannot read pending buttons: ${buttons.message}\n`)
121
+ for (const sessionId of sessionIds) {
122
+ const status = statuses.get(sessionId) ?? 'running'
123
+ const text = statusText({ sessionId, status, buttons: buttons instanceof Error ? [] : buttons.data[sessionId] ?? [], timeout })
124
+ const header = `=== ${sessionId} ${text} ===`
125
+ if (status === 'running') {
126
+ process.stdout.write(`${header}\n\n`)
127
+ continue
128
+ }
129
+ const markdown = await readSessionMarkdown({ client, sessionId })
130
+ if (markdown instanceof Error) fail(markdown)
131
+ process.stdout.write(`${header}\n\n${markdown}\n\n`)
132
+ }
133
+ return [...statuses.values()].every((status) => status !== 'running')
99
134
  }
100
135
 
101
136
  // Discord REST with the bot token: KIMAKI_BOT_TOKEN (no kimaki.db needed, e.g. CI), else saved credentials.
@@ -40,7 +40,12 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
40
40
  // Many files keep argument order, numbered, images and files mixed.
41
41
  fs.mkdirSync(path.join(server.root, 'other'), { recursive: true })
42
42
  for (const name of ['b.png', 'my shot.png', 'other/b.png']) fs.writeFileSync(path.join(server.root, name), 'png')
43
- await cli(['upload-to-discord', ...['b.png', 'report.txt', 'my shot.png', 'other/b.png'].map((name) => path.join(server.root, name)), '--session', started.sessionId])
43
+ const uploaded = await cli(['upload-to-discord', ...['b.png', 'report.txt', 'my shot.png', 'other/b.png'].map((name) => path.join(server.root, name)), '--session', started.sessionId])
44
+ // Each file with its Discord URL, in argument order, so a coordinator can repost them (the twin uses data: URLs).
45
+ expect(uploaded.stdout).toMatchInlineSnapshot(`
46
+ "{"uploaded":[{"name":"b.png","url":"data:application/octet-stream;base64,cG5n"},{"name":"report.txt","url":"data:application/octet-stream;base64,cmVwb3J0"},{"name":"my shot.png","url":"data:application/octet-stream;base64,cG5n"},{"name":"b.png","url":"data:application/octet-stream;base64,cG5n"}]}
47
+ "
48
+ `)
44
49
  // The agent learns that nothing was posted.
45
50
  const missing = await cli(['upload-to-discord', path.join(server.root, 'missing.png'), '--session', started.sessionId]).catch((error: { stderr: string }) => error)
46
51
  expect(missing.stderr.replace(server.root, '<root>')).toMatchInlineSnapshot(`
@@ -51,20 +56,20 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
51
56
  const install = await cli(['bot', 'install-url'])
52
57
  expect(await twin.discord.thread(started.threadId).text()).toMatchInlineSnapshot(`
53
58
  "--- from: assistant (TestBot)
54
- » **CLI:** Discord CLI flow
59
+ -# » **CLI:** Discord CLI flow
55
60
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
56
61
  ok
57
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
58
- [file: attachment://report.txt]
62
+ -# *main ⋅ Ns ⋅ deterministic-v2*
63
+ [file: data:application/octet-stream;base64,cmVwb3J0]
59
64
  [attachment: report.txt]
60
65
  -# 1. \`b.png\`
61
- [media: attachment://b.png]
66
+ [media: data:application/octet-stream;base64,cG5n]
62
67
  -# 2. \`report.txt\`
63
- [file: attachment://report.txt]
68
+ [file: data:application/octet-stream;base64,cmVwb3J0]
64
69
  -# 3. \`my shot.png\`
65
- [media: attachment://my_shot.png]
70
+ [media: data:application/octet-stream;base64,cG5n]
66
71
  -# 4. \`b.png\`
67
- [media: attachment://4-b.png]
72
+ [media: data:application/octet-stream;base64,cG5n]
68
73
  [attachment: b.png]
69
74
  [attachment: report.txt]
70
75
  [attachment: my_shot.png]