kimaki 1.3.0 → 1.5.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 (346) 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 +13 -6
  15. package/dist/bot.js +17 -15
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +3 -0
  19. package/dist/builtin-skills.js +29 -0
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +72 -1
  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.d.ts +1 -1
  25. package/dist/cli/bot.js +33 -18
  26. package/dist/cli/bot.js.map +1 -1
  27. package/dist/cli/discord.js +32 -6
  28. package/dist/cli/discord.js.map +1 -1
  29. package/dist/cli/schedule.js +23 -9
  30. package/dist/cli/schedule.js.map +1 -1
  31. package/dist/cli/session.js +37 -12
  32. package/dist/cli/session.js.map +1 -1
  33. package/dist/cli/shared.d.ts +5 -3
  34. package/dist/cli/shared.js +45 -10
  35. package/dist/cli/shared.js.map +1 -1
  36. package/dist/cli-discord.e2e.test.js +13 -8
  37. package/dist/cli-discord.e2e.test.js.map +1 -1
  38. package/dist/cli-lock.e2e.test.js +87 -20
  39. package/dist/cli-lock.e2e.test.js.map +1 -1
  40. package/dist/cli-read.e2e.test.js +18 -9
  41. package/dist/cli-read.e2e.test.js.map +1 -1
  42. package/dist/cli-sessions.e2e.test.js +4 -3
  43. package/dist/cli-sessions.e2e.test.js.map +1 -1
  44. package/dist/cli.js +2 -0
  45. package/dist/cli.js.map +1 -1
  46. package/dist/commands/preference-commands.d.ts +4 -0
  47. package/dist/commands/preference-commands.js +38 -4
  48. package/dist/commands/preference-commands.js.map +1 -1
  49. package/dist/commands/session-commands.js +11 -8
  50. package/dist/commands/session-commands.js.map +1 -1
  51. package/dist/commands/thread-commands.js +94 -24
  52. package/dist/commands/thread-commands.js.map +1 -1
  53. package/dist/commands/worktree-commands.d.ts +5 -6
  54. package/dist/commands/worktree-commands.js +8 -7
  55. package/dist/commands/worktree-commands.js.map +1 -1
  56. package/dist/commands.e2e.test.js +25 -21
  57. package/dist/commands.e2e.test.js.map +1 -1
  58. package/dist/coordinator-agents.e2e.test.d.ts +1 -0
  59. package/dist/coordinator-agents.e2e.test.js +113 -0
  60. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  61. package/dist/coordinator-idle.e2e.test.d.ts +1 -0
  62. package/dist/coordinator-idle.e2e.test.js +100 -0
  63. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  64. package/dist/coordinator-queue.e2e.test.d.ts +1 -0
  65. package/dist/coordinator-queue.e2e.test.js +244 -0
  66. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  67. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  68. package/dist/coordinator-warmup.e2e.test.js +131 -0
  69. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  70. package/dist/coordinator.d.ts +6 -0
  71. package/dist/coordinator.e2e.test.d.ts +1 -0
  72. package/dist/coordinator.e2e.test.js +505 -0
  73. package/dist/coordinator.e2e.test.js.map +1 -0
  74. package/dist/coordinator.js +164 -0
  75. package/dist/coordinator.js.map +1 -0
  76. package/dist/db.d.ts +78 -3
  77. package/dist/db.test.js +1 -0
  78. package/dist/db.test.js.map +1 -1
  79. package/dist/diagram.d.ts +11 -0
  80. package/dist/diagram.js +232 -0
  81. package/dist/diagram.js.map +1 -0
  82. package/dist/diagram.test.d.ts +1 -0
  83. package/dist/diagram.test.js +52 -0
  84. package/dist/diagram.test.js.map +1 -0
  85. package/dist/duration.js +13 -5
  86. package/dist/duration.js.map +1 -1
  87. package/dist/effects.d.ts +6 -1
  88. package/dist/effects.js +97 -26
  89. package/dist/effects.js.map +1 -1
  90. package/dist/event-loop.d.ts +2 -3
  91. package/dist/event-loop.js +74 -44
  92. package/dist/event-loop.js.map +1 -1
  93. package/dist/format-parts.d.ts +21 -4
  94. package/dist/format-parts.js +40 -23
  95. package/dist/format-parts.js.map +1 -1
  96. package/dist/gateway-replay.e2e.test.js +4 -4
  97. package/dist/harness.e2e.test.js +2 -2
  98. package/dist/harness.e2e.test.js.map +1 -1
  99. package/dist/hello-thread.e2e.test.js +56 -5
  100. package/dist/hello-thread.e2e.test.js.map +1 -1
  101. package/dist/ingress.js +47 -12
  102. package/dist/ingress.js.map +1 -1
  103. package/dist/interaction-context.d.ts +4 -4
  104. package/dist/interaction-context.js +9 -7
  105. package/dist/interaction-context.js.map +1 -1
  106. package/dist/interrupt.e2e.test.js +8 -5
  107. package/dist/interrupt.e2e.test.js.map +1 -1
  108. package/dist/legacy.e2e.test.js +1 -1
  109. package/dist/lock-routes.d.ts +83 -31
  110. package/dist/lock-routes.js +68 -8
  111. package/dist/lock-routes.js.map +1 -1
  112. package/dist/lock-server.d.ts +3 -2
  113. package/dist/lock-server.js +23 -7
  114. package/dist/lock-server.js.map +1 -1
  115. package/dist/main.d.ts +6 -3
  116. package/dist/main.js +14 -4
  117. package/dist/main.js.map +1 -1
  118. package/dist/mainmast-idle.e2e.test.d.ts +1 -0
  119. package/dist/mainmast-idle.e2e.test.js +99 -0
  120. package/dist/mainmast-idle.e2e.test.js.map +1 -0
  121. package/dist/mainmast-queue.e2e.test.d.ts +1 -0
  122. package/dist/mainmast-queue.e2e.test.js +244 -0
  123. package/dist/mainmast-queue.e2e.test.js.map +1 -0
  124. package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
  125. package/dist/mainmast-warmup.e2e.test.js +130 -0
  126. package/dist/mainmast-warmup.e2e.test.js.map +1 -0
  127. package/dist/mainmast.d.ts +6 -0
  128. package/dist/mainmast.e2e.test.d.ts +1 -0
  129. package/dist/mainmast.e2e.test.js +428 -0
  130. package/dist/mainmast.e2e.test.js.map +1 -0
  131. package/dist/mainmast.js +156 -0
  132. package/dist/mainmast.js.map +1 -0
  133. package/dist/markdown/markdown.test.js +14 -4
  134. package/dist/markdown/markdown.test.js.map +1 -1
  135. package/dist/markdown/render-markdown.js +9 -6
  136. package/dist/markdown/render-markdown.js.map +1 -1
  137. package/dist/onboarding-cli.e2e.test.js +7 -6
  138. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  139. package/dist/onboarding.d.ts +3 -10
  140. package/dist/onboarding.e2e.test.js +8 -10
  141. package/dist/onboarding.e2e.test.js.map +1 -1
  142. package/dist/onboarding.js +14 -24
  143. package/dist/onboarding.js.map +1 -1
  144. package/dist/permissions.e2e.test.js +3 -3
  145. package/dist/plugin/coordinator.d.ts +4 -0
  146. package/dist/plugin/coordinator.js +389 -0
  147. package/dist/plugin/coordinator.js.map +1 -0
  148. package/dist/plugin/index.js +93 -61
  149. package/dist/plugin/index.js.map +1 -1
  150. package/dist/plugin/mainmast.d.ts +4 -0
  151. package/dist/plugin/mainmast.js +379 -0
  152. package/dist/plugin/mainmast.js.map +1 -0
  153. package/dist/plugin.e2e.test.js +1 -1
  154. package/dist/preference-commands.e2e.test.js +103 -24
  155. package/dist/preference-commands.e2e.test.js.map +1 -1
  156. package/dist/project.d.ts +1 -1
  157. package/dist/prompt.d.ts +22 -9
  158. package/dist/prompt.js +64 -48
  159. package/dist/prompt.js.map +1 -1
  160. package/dist/questions.e2e.test.js +13 -13
  161. package/dist/queue-sources.e2e.test.d.ts +1 -0
  162. package/dist/queue-sources.e2e.test.js +168 -0
  163. package/dist/queue-sources.e2e.test.js.map +1 -0
  164. package/dist/queue.d.ts +9 -1
  165. package/dist/queue.e2e.test.js +48 -30
  166. package/dist/queue.e2e.test.js.map +1 -1
  167. package/dist/queue.js +97 -24
  168. package/dist/queue.js.map +1 -1
  169. package/dist/queue.test.d.ts +1 -0
  170. package/dist/queue.test.js +57 -0
  171. package/dist/queue.test.js.map +1 -0
  172. package/dist/remote-send.e2e.test.js +6 -6
  173. package/dist/remote-send.js +2 -1
  174. package/dist/remote-send.js.map +1 -1
  175. package/dist/renderer.e2e.test.js +7 -7
  176. package/dist/restart.e2e.test.js +2 -2
  177. package/dist/routines.e2e.test.js +2 -2
  178. package/dist/scheduler.d.ts +3 -3
  179. package/dist/scheduler.e2e.test.js +43 -20
  180. package/dist/scheduler.e2e.test.js.map +1 -1
  181. package/dist/scheduler.js +4 -2
  182. package/dist/scheduler.js.map +1 -1
  183. package/dist/schema-sql.d.ts +1 -1
  184. package/dist/schema-sql.js +1 -1
  185. package/dist/schema-sql.js.map +1 -1
  186. package/dist/schema.d.ts +152 -6
  187. package/dist/schema.js +12 -0
  188. package/dist/schema.js.map +1 -1
  189. package/dist/session-commands.e2e.test.js +36 -11
  190. package/dist/session-commands.e2e.test.js.map +1 -1
  191. package/dist/session-events.d.ts +18 -6
  192. package/dist/session-events.js +175 -27
  193. package/dist/session-events.js.map +1 -1
  194. package/dist/session-ops.e2e.test.js +63 -12
  195. package/dist/session-ops.e2e.test.js.map +1 -1
  196. package/dist/sessions.d.ts +44 -21
  197. package/dist/sessions.js +177 -65
  198. package/dist/sessions.js.map +1 -1
  199. package/dist/shell.e2e.test.js +4 -4
  200. package/dist/slash-commands.js +6 -4
  201. package/dist/slash-commands.js.map +1 -1
  202. package/dist/slash-commands.test.js +2 -0
  203. package/dist/slash-commands.test.js.map +1 -1
  204. package/dist/sleep.e2e.test.js +29 -14
  205. package/dist/sleep.e2e.test.js.map +1 -1
  206. package/dist/sleeps.d.ts +2 -1
  207. package/dist/sleeps.js +21 -3
  208. package/dist/sleeps.js.map +1 -1
  209. package/dist/store.d.ts +1 -0
  210. package/dist/store.js +3 -2
  211. package/dist/store.js.map +1 -1
  212. package/dist/system-prompt.d.ts +5 -15
  213. package/dist/system-prompt.js +180 -131
  214. package/dist/system-prompt.js.map +1 -1
  215. package/dist/test/generate-log-plugin/index.d.ts +3 -0
  216. package/dist/test/generate-log-plugin/index.js +15 -0
  217. package/dist/test/generate-log-plugin/index.js.map +1 -0
  218. package/dist/test/harness.d.ts +20 -2
  219. package/dist/test/harness.js +51 -11
  220. package/dist/test/harness.js.map +1 -1
  221. package/dist/test/replay.js +11 -8
  222. package/dist/test/replay.js.map +1 -1
  223. package/dist/thread-reducer.d.ts +29 -6
  224. package/dist/thread-reducer.fixtures.test.js +58 -51
  225. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  226. package/dist/thread-reducer.js +109 -50
  227. package/dist/thread-reducer.js.map +1 -1
  228. package/dist/thread-reducer.test.js +190 -48
  229. package/dist/thread-reducer.test.js.map +1 -1
  230. package/dist/tunnel.d.ts +9 -0
  231. package/dist/tunnel.js +117 -28
  232. package/dist/tunnel.js.map +1 -1
  233. package/dist/tunnel.test.js +81 -5
  234. package/dist/tunnel.test.js.map +1 -1
  235. package/dist/voice-attachments.e2e.test.js +108 -18
  236. package/dist/voice-attachments.e2e.test.js.map +1 -1
  237. package/dist/voice-call.e2e.test.js +9 -9
  238. package/dist/voice-call.js +6 -6
  239. package/dist/voice-call.js.map +1 -1
  240. package/dist/voice-openai.e2e.test.js +59 -23
  241. package/dist/voice-openai.e2e.test.js.map +1 -1
  242. package/dist/voice.d.ts +1 -1
  243. package/dist/voice.js +72 -24
  244. package/dist/voice.js.map +1 -1
  245. package/dist/working-directory.e2e.test.js +11 -11
  246. package/dist/working-directory.e2e.test.js.map +1 -1
  247. package/package.json +11 -5
  248. package/skills/kimaki-tunnel/SKILL.md +3 -2
  249. package/src/agent-ui.e2e.test.ts +27 -13
  250. package/src/agent-ui.ts +2 -1
  251. package/src/analytics.e2e.test.ts +1 -1
  252. package/src/autostart.ts +55 -10
  253. package/src/bot.ts +36 -19
  254. package/src/btw.e2e.test.ts +3 -3
  255. package/src/builtin-skills.test.ts +73 -1
  256. package/src/builtin-skills.ts +33 -0
  257. package/src/catalog-refresh.e2e.test.ts +2 -2
  258. package/src/cli/bot.ts +30 -19
  259. package/src/cli/discord.ts +30 -6
  260. package/src/cli/schedule.ts +23 -9
  261. package/src/cli/session.ts +35 -12
  262. package/src/cli/shared.ts +43 -8
  263. package/src/cli-discord.e2e.test.ts +13 -8
  264. package/src/cli-lock.e2e.test.ts +88 -20
  265. package/src/cli-read.e2e.test.ts +17 -8
  266. package/src/cli-sessions.e2e.test.ts +4 -3
  267. package/src/cli.ts +2 -0
  268. package/src/commands/preference-commands.ts +38 -4
  269. package/src/commands/session-commands.ts +12 -7
  270. package/src/commands/thread-commands.ts +92 -24
  271. package/src/commands/worktree-commands.ts +10 -9
  272. package/src/commands.e2e.test.ts +25 -21
  273. package/src/coordinator-agents.e2e.test.ts +145 -0
  274. package/src/coordinator-idle.e2e.test.ts +122 -0
  275. package/src/coordinator-queue.e2e.test.ts +279 -0
  276. package/src/coordinator-warmup.e2e.test.ts +158 -0
  277. package/src/coordinator.e2e.test.ts +545 -0
  278. package/src/coordinator.ts +168 -0
  279. package/src/db.test.ts +1 -0
  280. package/src/diagram.test.ts +56 -0
  281. package/src/diagram.ts +243 -0
  282. package/src/duration.ts +13 -5
  283. package/src/effects.ts +99 -27
  284. package/src/event-loop.ts +71 -45
  285. package/src/format-parts.ts +53 -22
  286. package/src/gateway-replay.e2e.test.ts +4 -4
  287. package/src/harness.e2e.test.ts +2 -2
  288. package/src/hello-thread.e2e.test.ts +59 -5
  289. package/src/ingress.ts +44 -11
  290. package/src/interaction-context.ts +12 -11
  291. package/src/interrupt.e2e.test.ts +8 -5
  292. package/src/legacy.e2e.test.ts +1 -1
  293. package/src/lock-routes.ts +62 -8
  294. package/src/lock-server.ts +23 -7
  295. package/src/main.ts +26 -10
  296. package/src/markdown/markdown.test.ts +15 -4
  297. package/src/markdown/render-markdown.ts +9 -6
  298. package/src/onboarding-cli.e2e.test.ts +7 -6
  299. package/src/onboarding.e2e.test.ts +8 -10
  300. package/src/onboarding.ts +13 -29
  301. package/src/permissions.e2e.test.ts +3 -3
  302. package/src/plugin/coordinator.ts +401 -0
  303. package/src/plugin/index.ts +86 -59
  304. package/src/plugin.e2e.test.ts +1 -1
  305. package/src/preference-commands.e2e.test.ts +103 -23
  306. package/src/prompt.ts +87 -43
  307. package/src/questions.e2e.test.ts +13 -13
  308. package/src/queue-sources.e2e.test.ts +197 -0
  309. package/src/queue.e2e.test.ts +48 -30
  310. package/src/queue.test.ts +59 -0
  311. package/src/queue.ts +104 -24
  312. package/src/remote-send.e2e.test.ts +6 -6
  313. package/src/remote-send.ts +2 -1
  314. package/src/renderer.e2e.test.ts +7 -7
  315. package/src/restart.e2e.test.ts +2 -2
  316. package/src/routines.e2e.test.ts +2 -2
  317. package/src/scheduler.e2e.test.ts +45 -19
  318. package/src/scheduler.ts +4 -2
  319. package/src/schema-sql.ts +1 -1
  320. package/src/schema.ts +13 -0
  321. package/src/session-commands.e2e.test.ts +38 -11
  322. package/src/session-events.ts +168 -25
  323. package/src/session-ops.e2e.test.ts +64 -11
  324. package/src/sessions.ts +217 -67
  325. package/src/shell.e2e.test.ts +4 -4
  326. package/src/slash-commands.test.ts +2 -0
  327. package/src/slash-commands.ts +6 -4
  328. package/src/sleep.e2e.test.ts +30 -13
  329. package/src/sleeps.ts +20 -3
  330. package/src/store.ts +5 -2
  331. package/src/system-prompt.ts +190 -134
  332. package/src/test/generate-log-plugin/index.ts +16 -0
  333. package/src/test/harness.ts +77 -17
  334. package/src/test/replay.ts +12 -7
  335. package/src/thread-reducer.fixtures.test.ts +58 -51
  336. package/src/thread-reducer.test.ts +199 -49
  337. package/src/thread-reducer.ts +135 -66
  338. package/src/tunnel.test.ts +81 -5
  339. package/src/tunnel.ts +124 -25
  340. package/src/voice-attachments.e2e.test.ts +110 -18
  341. package/src/voice-call.e2e.test.ts +9 -9
  342. package/src/voice-call.ts +6 -6
  343. package/src/voice-openai.e2e.test.ts +64 -24
  344. package/src/voice.ts +93 -26
  345. package/src/working-directory.e2e.test.ts +11 -11
  346. package/src/message-tool.e2e.test.ts +0 -45
package/src/tunnel.ts CHANGED
@@ -5,16 +5,22 @@
5
5
 
6
6
  import crypto from 'node:crypto'
7
7
  import { execFile, spawn, type ChildProcess } from 'node:child_process'
8
+ import http from 'node:http'
8
9
  import net from 'node:net'
9
10
  import { setTimeout as sleep } from 'node:timers/promises'
10
11
  import { promisify, stripVTControlCharacters } from 'node:util'
11
- import { create as createOpenTunnel, type OpenTunnelConnection } from '@opentunnel/client'
12
+ import {
13
+ create as createOpenTunnel,
14
+ isValidRouteName,
15
+ OpenTunnelClientError,
16
+ publicUrl,
17
+ type OpenTunnelClientEvent,
18
+ type OpenTunnelConnection,
19
+ } from '@opentunnel/client'
12
20
 
13
21
  const execFileAsync = promisify(execFile)
14
22
 
15
- const ROUTE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
16
23
  const PORT_WAIT_MS = 30_000
17
- const ATTACH_TIMEOUT_MS = 60_000
18
24
  const CHILD_KILL_GRACE_MS = 5_000
19
25
 
20
26
  // Only lines with host:port, so "Port 5173 is in use" does not match.
@@ -31,12 +37,28 @@ export function detectPortFromText(text: string): number | null {
31
37
  return null
32
38
  }
33
39
 
40
+ // Traforo accepted any tunnel ID, so map it to a DNS label instead of failing: `My_App` becomes `my-app`.
41
+ function toRouteLabel(raw: string): string {
42
+ const label = raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63).replace(/-+$/, '')
43
+ return isValidRouteName(label) ? label : ''
44
+ }
45
+
34
46
  // Explicit name, then the tuistory session ID (stable URL across restarts of one session), then random.
35
47
  export function routeName({ name, tuistorySessionId }: { name?: string; tuistorySessionId?: string }): string | Error {
36
- if (name) return ROUTE_NAME.test(name) ? name : new Error(`Invalid tunnel ID ${JSON.stringify(name)}: use lowercase letters, digits and dashes`)
37
- const fromSession = tuistorySessionId?.toLowerCase()
38
- if (fromSession && ROUTE_NAME.test(fromSession)) return fromSession
39
- return crypto.randomBytes(8).toString('hex')
48
+ if (name) return toRouteLabel(name) || new Error(`Invalid tunnel ID ${JSON.stringify(name)}: use letters, digits and dashes`)
49
+ return toRouteLabel(tuistorySessionId ?? '') || crypto.randomBytes(8).toString('hex')
50
+ }
51
+
52
+ // Parses `netstat -ano` output on Windows: `TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234`.
53
+ export function parseNetstatListeningPids({ stdout, port }: { stdout: string; port: number }): number[] {
54
+ const pids = new Set<number>()
55
+ for (const line of stdout.split('\n')) {
56
+ const [protocol, local, , state, pid] = line.trim().split(/\s+/)
57
+ if (protocol !== 'TCP' || state !== 'LISTENING' || !local?.endsWith(`:${port}`)) continue
58
+ const parsed = Number(pid)
59
+ if (Number.isInteger(parsed) && parsed > 0) pids.add(parsed)
60
+ }
61
+ return [...pids]
40
62
  }
41
63
 
42
64
  function isPortInUse({ port, host }: { port: number; host: string }): Promise<boolean> {
@@ -55,6 +77,10 @@ async function waitForPort({ port, host, signal }: { port: number; host: string;
55
77
  }
56
78
 
57
79
  async function listeningPids(port: number): Promise<number[]> {
80
+ if (process.platform === 'win32') {
81
+ const { stdout } = await execFileAsync('netstat', ['-ano'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
82
+ return parseNetstatListeningPids({ stdout, port })
83
+ }
58
84
  const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
59
85
  return stdout.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0)
60
86
  }
@@ -107,6 +133,69 @@ function killChild(child: ChildProcess | undefined, signal: NodeJS.Signals = 'SI
107
133
 
108
134
  const log = (line: string) => process.stderr.write(`${line}\n`)
109
135
 
136
+ function logTunnelEvent(event: OpenTunnelClientEvent) {
137
+ if (event.type === 'disconnected') log(`Tunnel disconnected: ${event.reason}`)
138
+ if (event.type === 'connected') log('Tunnel connected')
139
+ }
140
+
141
+ function forwardedHeaders({ headers, target }: { headers: http.IncomingHttpHeaders; target: string }): http.OutgoingHttpHeaders {
142
+ return {
143
+ ...headers,
144
+ host: target,
145
+ 'x-forwarded-host': headers['x-forwarded-host'] ?? headers.host,
146
+ 'x-forwarded-proto': headers['x-forwarded-proto'] ?? 'https',
147
+ }
148
+ }
149
+
150
+ function rawResponseHead(response: http.IncomingMessage): string {
151
+ let head = `HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n`
152
+ for (let i = 0; i < response.rawHeaders.length; i += 2) head += `${response.rawHeaders[i]}: ${response.rawHeaders[i + 1]}\r\n`
153
+ return `${head}\r\n`
154
+ }
155
+
156
+ // opentunnel forwards the raw bytes, so the app would see the public Host header. Vite and other dev
157
+ // servers reject unknown hosts (DNS rebinding protection) with 403. This loopback proxy sends
158
+ // Host: localhost:<port> and keeps the public host in X-Forwarded-Host, like traforo did.
159
+ export function startHostProxy({ host, port }: { host: string; port: number }): Promise<http.Server | Error> {
160
+ const target = `${host}:${port}`
161
+ const server = http.createServer((req, res) => {
162
+ const upstream = http.request({ host, port, method: req.method, path: req.url, headers: forwardedHeaders({ headers: req.headers, target }) }, (response) => {
163
+ res.writeHead(response.statusCode ?? 502, response.statusMessage, response.rawHeaders)
164
+ response.pipe(res)
165
+ })
166
+ upstream.on('error', () => {
167
+ if (!res.headersSent) res.writeHead(502)
168
+ res.end()
169
+ })
170
+ req.pipe(upstream)
171
+ })
172
+
173
+ // WebSocket upgrades (Vite HMR): replay the handshake upstream, then pipe both sockets.
174
+ server.on('upgrade', (req, socket, head) => {
175
+ const upstream = http.request({ host, port, method: req.method, path: req.url, headers: forwardedHeaders({ headers: req.headers, target }) })
176
+ upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
177
+ socket.write(rawResponseHead(response))
178
+ if (upstreamHead.length > 0) socket.write(upstreamHead)
179
+ if (head.length > 0) upstreamSocket.write(head)
180
+ upstreamSocket.on('error', () => socket.destroy())
181
+ socket.on('error', () => upstreamSocket.destroy())
182
+ upstreamSocket.pipe(socket).pipe(upstreamSocket)
183
+ })
184
+ upstream.on('response', (response) => {
185
+ socket.write(rawResponseHead(response))
186
+ response.pipe(socket)
187
+ })
188
+ upstream.on('error', () => socket.destroy())
189
+ socket.on('error', () => upstream.destroy())
190
+ upstream.end()
191
+ })
192
+
193
+ return new Promise((resolve) => {
194
+ server.once('error', (cause) => resolve(new Error('Could not start the tunnel proxy', { cause })))
195
+ server.listen(0, '127.0.0.1', () => resolve(server))
196
+ })
197
+ }
198
+
110
199
  export async function runTunnel(options: {
111
200
  port?: number
112
201
  command?: string[]
@@ -127,11 +216,18 @@ export async function runTunnel(options: {
127
216
  }
128
217
 
129
218
  const client = createOpenTunnel()
130
- const existing = await client.tunnel.get().catch(() => undefined)
131
- if (!existing) log('Creating the opentunnel.xyz tunnel of this machine (waits for a certificate, up to a minute)...')
132
- const identity = await client.tunnel.ensure().catch((cause: unknown) => new Error('Could not create the opentunnel tunnel', { cause }))
219
+ let waitLogged = false
220
+ const identity = await client.tunnel
221
+ .ensure({
222
+ onProgress: (stage) => {
223
+ if (waitLogged || stage === 'checking-certificate' || stage === 'ready') return
224
+ waitLogged = true
225
+ log('Getting a certificate for the opentunnel.xyz tunnel of this machine (up to a minute)...')
226
+ },
227
+ })
228
+ .catch((cause) => new Error('Could not create the opentunnel tunnel', { cause }))
133
229
  if (identity instanceof Error) return identity
134
- const url = `https://${route}.${identity.hostname}`
230
+ const url = publicUrl(route, identity.hostname)
135
231
 
136
232
  let child: ChildProcess | undefined
137
233
  let connection: OpenTunnelConnection | undefined
@@ -186,24 +282,27 @@ export async function runTunnel(options: {
186
282
  if (!port) return fail(new Error('Could not find the local port. Pass --port'))
187
283
  const ready = await waitForPort({ port, host, signal: portSignal })
188
284
  if (ready instanceof Error) return fail(ready)
285
+ const proxy = await startHostProxy({ host, port })
286
+ if (proxy instanceof Error) return fail(proxy)
287
+ const proxyAddress = proxy.address()
288
+ if (!proxyAddress || typeof proxyAddress === 'string') return fail(new Error('Tunnel proxy has no TCP address'))
189
289
 
190
- // connect retries route_conflict forever, so a route held by another process would hang here.
191
- const connecting = client.tunnel.connect({ routes: { [route]: `${host}:${port}` } })
192
- const timeout = sleep(ATTACH_TIMEOUT_MS, null, { ref: false })
193
- const attached = await Promise.race([connecting, timeout]).catch((cause: unknown) => new Error('Could not connect the tunnel', { cause }))
290
+ const attached = await client.tunnel
291
+ .connect({ routes: { [route]: `127.0.0.1:${proxyAddress.port}` }, onRouteConflict: 'fail', onEvent: logTunnelEvent })
292
+ .catch((cause) => {
293
+ if (cause instanceof OpenTunnelClientError && cause.code === 'route_conflict') {
294
+ return new Error(`Tunnel ID ${route} is already used by another process. Pass a different -t`, { cause })
295
+ }
296
+ return new Error('Could not connect the tunnel', { cause })
297
+ })
194
298
  if (attached instanceof Error) return fail(attached)
195
- if (!attached) return fail(new Error(`Tunnel did not attach in ${ATTACH_TIMEOUT_MS / 1000}s. Another process may use the tunnel ID ${route}`))
196
299
  connection = attached
197
300
 
198
- process.stdout.write(`\nLocal: http://${host}:${port}\nTunnel: ${url}\n\n`)
199
-
200
- void (async () => {
201
- for await (const event of attached.events) {
202
- if (event.type === 'disconnected') log(`Tunnel disconnected: ${event.reason}`)
203
- if (event.type === 'connected') log('Tunnel connected')
204
- }
205
- })()
301
+ process.stdout.write(
302
+ `\nLocal: http://${host}:${port}\nTunnel: ${url}\n` +
303
+ `Agents: validate with the Local URL (playwriter, curl), it is much faster. The Tunnel URL is for the user.\n\n`,
304
+ )
206
305
 
207
- const closed = await attached.closed.then(() => undefined, (cause: unknown) => new Error('Tunnel stopped', { cause }))
306
+ const closed = await attached.closed.then(() => undefined, (cause) => new Error('Tunnel stopped', { cause }))
208
307
  return fail(closed ?? new Error('Tunnel stopped'))
209
308
  }
@@ -39,9 +39,9 @@ const matchers: DeterministicMatcher[] = [
39
39
  {
40
40
  id: 'image',
41
41
  priority: 500,
42
- // The plugin adds the numbered <attachments> and <discord-user> to the model request only.
42
+ // The plugin adds the numbered <attachments>, the concise reminder and <discord-user> to the model request only.
43
43
  when: {
44
- latestUserTextRegex: 'image-marker[\\s\\S]*<attachments>[\\s\\S]*1\\. dot\\.png \\(inline\\): [^\\n]*\\n2\\. notes\\.txt: [^\\n]*\\n3\\. archive\\.zip: [^\\n]*\\n</attachments>\\n<discord-user ',
44
+ latestUserTextRegex: 'image-marker[\\s\\S]*<attachments>[\\s\\S]*1\\. dot\\.png \\(inline\\): [^\\n]*\\n2\\. notes\\.txt: [^\\n]*\\n3\\. archive\\.zip: [^\\n]*\\n</attachments>\\n<system-reminder>[^\\n]*</system-reminder>\\n<discord-user ',
45
45
  rawPromptIncludes: 'image/png',
46
46
  },
47
47
  then: { parts: textParts('I see an image') },
@@ -51,6 +51,10 @@ const matchers: DeterministicMatcher[] = [
51
51
  reply('voice-btw', 'voice btw ok'),
52
52
  reply('voice-new', 'voice new session ok'),
53
53
  reply('voice-plan', 'voice plan ok'),
54
+ reply('order-start', 'order start ok'),
55
+ // Above order-start: the thread name in <discord-user> repeats that marker.
56
+ { ...reply('order-voice', 'order voice ok'), priority: 600 },
57
+ { ...reply('order-text', 'order text ok'), priority: 600 },
54
58
  ]
55
59
 
56
60
  let server: OpencodeTestServer
@@ -130,7 +134,7 @@ test('image and text attachments reach the model as files', async () => {
130
134
  --- from: assistant (TestBot)
131
135
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
132
136
  I see an image
133
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
137
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
134
138
  `)
135
139
  const sessionId = bot.store.getState().roots[thread.id]!
136
140
  const messages = await (await server.client()).message.list({ sessionID: sessionId })
@@ -156,13 +160,78 @@ test('voice in a channel starts a session with the transcription and the spoken
156
160
  "--- from: user (tommy)
157
161
  [attachment: voice-message.ogg]
158
162
  --- from: assistant (TestBot)
159
- » **tommy:** Plan the refactor voice-plan
163
+ -# » **tommy:** Plan the refactor voice-plan
160
164
  -# *using deterministic-provider/deterministic-v2 ⋅ plan*
161
165
  voice plan ok
162
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
166
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
163
167
  `)
164
168
  })
165
169
 
170
+ test('a text sent while a voice message is transcribed waits for it', async () => {
171
+ const thread = await newThread(() => twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'Start order-start' }))
172
+ await waitForFooter({ discord: twin.discord, threadId: thread.id })
173
+ const user = twin.discord.thread(thread.id).user(TEST_USER_ID)
174
+ await user.sendVoiceMessage({ url: voiceUrl({ transcription: 'Spoken first order-voice', route: 'queue', delayMs: 500 }) })
175
+ await user.sendMessage({ content: 'Typed second order-text' })
176
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: thread.id, text: 'order text ok' })
177
+ await waitForFooter({ discord: twin.discord, threadId: thread.id, count: 2 })
178
+ expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
179
+ "--- from: user (tommy)
180
+ Start order-start
181
+ --- from: assistant (TestBot)
182
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
183
+ order start ok
184
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
185
+ --- from: user (tommy)
186
+ [attachment: voice-message.ogg]
187
+ Typed second order-text
188
+ --- from: assistant (TestBot)
189
+ -# » **tommy:** Spoken first order-voice
190
+ order text ok
191
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
192
+ `)
193
+ const sessionId = bot.store.getState().roots[thread.id]!
194
+ const messages = await (await server.client()).message.list({ sessionID: sessionId, order: 'asc' })
195
+ expect(messages.data.flatMap((message) => (message.type === 'user' ? [message.text] : []))).toMatchInlineSnapshot(`
196
+ [
197
+ "Start order-start",
198
+ "Spoken first order-voice",
199
+ "Typed second order-text",
200
+ ]
201
+ `)
202
+ })
203
+
204
+ // https://github.com/remorses/kimaki/issues/246
205
+ test('a long voice transcript is echoed in full over several messages, in a channel and in a thread', async () => {
206
+ const words = Array.from({ length: 450 }, (_, index) => `w${index}`)
207
+ const transcription = `Long voice-steer ${words.join(' ')}`
208
+ const voice = { url: voiceUrl({ transcription, route: 'steer' }) }
209
+ const thread = await newThread(() => twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendVoiceMessage(voice))
210
+ await waitForFooter({ discord: twin.discord, threadId: thread.id })
211
+ await twin.discord.thread(thread.id).user(TEST_USER_ID).sendVoiceMessage(voice)
212
+ await waitForFooter({ discord: twin.discord, threadId: thread.id, count: 2 })
213
+ const text = await twin.discord.thread(thread.id).text()
214
+ expect(text.replace(/w\d+( w\d+)*/g, (run) => `<${run.split(' ')[0]}..${run.split(' ').at(-1)}>`)).toMatchInlineSnapshot(`
215
+ "--- from: user (tommy)
216
+ [attachment: voice-message.ogg]
217
+ --- from: assistant (TestBot)
218
+ -# » **tommy:** Long voice-steer <w0..w414>
219
+ -# <w415..w449>
220
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
221
+ voice steer ok
222
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
223
+ --- from: user (tommy)
224
+ [attachment: voice-message.ogg]
225
+ --- from: assistant (TestBot)
226
+ -# » **tommy:** Long voice-steer <w0..w414>
227
+ -# <w415..w449>
228
+ voice steer ok
229
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
230
+ `)
231
+ const echoed = (await twin.discord.thread(thread.id).getMessages()).map((message) => message.content).filter((content) => /\bw\d/.test(content))
232
+ expect(echoed.join(' ').replaceAll('-# ', '')).toBe(`» **tommy:** ${transcription} » **tommy:** ${transcription}`)
233
+ })
234
+
166
235
  // https://github.com/remorses/kimaki/issues/235
167
236
  test('uploaded audio files with odd Discord media types are transcribed', async () => {
168
237
  const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
@@ -179,10 +248,10 @@ test('uploaded audio files with odd Discord media types are transcribed', async
179
248
  "--- from: user (tommy)
180
249
  [attachment: memo.m4a]
181
250
  --- from: assistant (TestBot)
182
- » **tommy:** From m4a voice-steer
251
+ -# » **tommy:** From m4a voice-steer
183
252
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
184
253
  voice steer ok
185
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
254
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
186
255
  `)
187
256
  expect([m4a.name, mp3.name]).toMatchInlineSnapshot(`
188
257
  [
@@ -217,7 +286,7 @@ test('voice in a busy thread: queue waits, btw forks, new-session starts a threa
217
286
  await waitForFooter({ discord: twin.discord, threadId: thread.id, count: 2 })
218
287
 
219
288
  const hide = (text: string) => text.replace(/<#\d+>/g, '<#THREAD>')
220
- expect(hide(await twin.discord.thread(thread.id).text())).toMatchInlineSnapshot(`
289
+ expect(hide(await twin.discord.thread(thread.id).text({ showReplies: true }))).toMatchInlineSnapshot(`
221
290
  "--- from: user (tommy)
222
291
  Long work slow-marker
223
292
  --- from: assistant (TestBot)
@@ -225,23 +294,30 @@ test('voice in a busy thread: queue waits, btw forks, new-session starts a threa
225
294
  --- from: user (tommy)
226
295
  [attachment: voice-message.ogg]
227
296
  --- from: assistant (TestBot)
228
- » **tommy:** Afterwards voice-queue
229
- -# Queued message sent
297
+ [reply to tommy: ]
298
+ -# » **tommy:** Afterwards voice-queue
299
+ [reply to tommy: ]
300
+ -# Queued
230
301
  --- from: user (tommy)
231
302
  [attachment: voice-message.ogg]
232
303
  --- from: assistant (TestBot)
233
- » **tommy:** Side voice-btw
304
+ [reply to tommy: ]
305
+ -# » **tommy:** Side voice-btw
306
+ [reply to tommy: ]
234
307
  Session forked! Continue in <#THREAD>
235
308
  --- from: user (tommy)
236
309
  [attachment: voice-message.ogg]
237
310
  --- from: assistant (TestBot)
238
- » **tommy:** Fresh start voice-new
311
+ [reply to tommy: ]
312
+ -# » **tommy:** Fresh start voice-new
313
+ [reply to tommy: ]
239
314
  Started a new session in <#THREAD>
240
315
  slow-done
241
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
242
- » **tommy:** Afterwards voice-queue
316
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
317
+ [reply to tommy: ]
318
+ -# Queued message sent
243
319
  voice queue ok
244
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
320
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
245
321
  `)
246
322
  expect([btw.name, fresh.name]).toMatchInlineSnapshot(`
247
323
  [
@@ -254,14 +330,14 @@ test('voice in a busy thread: queue waits, btw forks, new-session starts a threa
254
330
  Reusing context from <#THREAD> to answer prompt...
255
331
  Side voice-btw
256
332
  voice btw ok
257
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
333
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
258
334
  `)
259
335
  expect(hide(await twin.discord.thread(fresh.id).text())).toMatchInlineSnapshot(`
260
336
  "--- from: assistant (TestBot)
261
- » **tommy:** Fresh start voice-new
337
+ -# » **tommy:** Fresh start voice-new
262
338
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
263
339
  voice new session ok
264
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
340
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
265
341
  `)
266
342
  // The transcription request offers btw and queue only inside a session thread.
267
343
  expect(gemini.requests.map((request) => request.routes)).toMatchInlineSnapshot(`
@@ -270,6 +346,22 @@ test('voice in a busy thread: queue waits, btw forks, new-session starts a threa
270
346
  "steer",
271
347
  "new-session",
272
348
  ],
349
+ [
350
+ "steer",
351
+ "queue",
352
+ "btw",
353
+ "new-session",
354
+ ],
355
+ [
356
+ "steer",
357
+ "new-session",
358
+ ],
359
+ [
360
+ "steer",
361
+ "queue",
362
+ "btw",
363
+ "new-session",
364
+ ],
273
365
  [
274
366
  "steer",
275
367
  "new-session",
@@ -136,12 +136,12 @@ test('a user joins and speaks, the backend runs kimaki commands and the model sa
136
136
  // Thread IDs are snowflakes: replace them for a stable snapshot.
137
137
  expect((await twin.discord.channel(voiceChannelId).text()).replace(/<#\d+>/g, '<#thread>')).toMatchInlineSnapshot(`
138
138
  "--- from: assistant (TestBot)
139
- -# ⬦ voice call started ⋅ gpt-live ⋅ cove
139
+ -# voice call started ⋅ gpt-live ⋅ cove
140
140
  Hello tommy.
141
- » **tommy:** Which projects do I have?
141
+ -# » **tommy:** Which projects do I have?
142
142
  -# ┣ shell _seq 1 2000; kimaki project list_
143
143
  -# ┣ shell _kimaki send --channel 200000000000000100 --prompt 'Voice call note' --notify-on…_
144
- -# ⬦ session started in <#thread>
144
+ -# session started in <#thread>
145
145
  -# ⨯ post\\_message _text must not be empty_
146
146
  You have one project."
147
147
  `)
@@ -205,12 +205,12 @@ test('chat messages reach the backend, and end_call leaves after the goodbye whi
205
205
  expect(botPackets() - packetsBefore).toBeGreaterThanOrEqual(30)
206
206
  expect(live.backendInputs.at(-1)).toBe(`<chat-message from="tommy" user-id="${TEST_USER_ID}">\nplease hang up\n</chat-message>`)
207
207
  expect(await chatSince(before)).toMatchInlineSnapshot(`
208
- "TestBot: -# ⬦ voice call started ⋅ gpt-live ⋅ cove
208
+ "TestBot: -# voice call started ⋅ gpt-live ⋅ cove
209
209
  TestBot: Hi again.
210
210
  tommy: please hang up
211
211
  TestBot: -# ┣ end\\_call
212
212
  TestBot: Bye.
213
- TestBot: -# ⬦ voice call ended: the assistant hung up"
213
+ TestBot: -# voice call ended: the assistant hung up"
214
214
  `)
215
215
  await twin.discord.channel(voiceChannelId).user(TEST_USER_ID).leaveVoice()
216
216
  })
@@ -244,15 +244,15 @@ test('finished threads of any project are announced in the call; the model answe
244
244
  live.replies.length = 0
245
245
 
246
246
  expect(await chatSince(before)).toMatchInlineSnapshot(`
247
- "TestBot: -# ⬦ voice call started ⋅ gpt-live ⋅ cove
247
+ "TestBot: -# voice call started ⋅ gpt-live ⋅ cove
248
248
  TestBot: Hi.
249
249
  tommy: start a thread
250
250
  TestBot: -# ┣ shell _kimaki send --channel <id> --prompt 'call-thread-marker' --user '…_
251
- TestBot: -# ⬦ session started in <#<id>>
251
+ TestBot: -# session started in <#<id>>
252
252
  TestBot: Started.
253
- TestBot: -# ⬦ thread finished: call-thread-marker ⋅ https://discord.com/channels/<id>/<id> ⋅ <ses>
253
+ TestBot: -# thread finished: call-thread-marker ⋅ https://discord.com/channels/<id>/<id> ⋅ <ses>
254
254
  TestBot: Your thread finished.
255
- TestBot: -# ⬦ thread finished: other-thread-marker ⋅ https://discord.com/channels/<id>/<id> ⋅ <ses>"
255
+ TestBot: -# thread finished: other-thread-marker ⋅ https://discord.com/channels/<id>/<id> ⋅ <ses>"
256
256
  `)
257
257
  const notices = live.appends.filter((append) => append.text.startsWith('<system>Thread finished'))
258
258
  expect(notices.map((notice) => ({ ...notice, text: notice.text.replace(/\d{17,20}/g, '<id>').replace(/ses_\w+/g, '<ses>') }))).toMatchInlineSnapshot(`
package/src/voice-call.ts CHANGED
@@ -330,7 +330,7 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
330
330
 
331
331
  // Bot lines in the text chat of the voice channel, through the same FIFO as session output.
332
332
  function post(bot: Bot, { channelId, text, markdown = false }: { channelId: string; text: string; markdown?: boolean }) {
333
- bot.effects.run(channelId, [markdown ? { type: 'markdown', text, blankLineBefore: false } : { type: 'send', text }])
333
+ bot.effects.run(channelId, [markdown ? { type: 'markdown', text, blankLineBefore: false, replyTo: null } : { type: 'send', text }])
334
334
  }
335
335
 
336
336
  // Errors during a call go to the log and to the text chat of the voice channel.
@@ -505,7 +505,7 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
505
505
  sent.push(threadId)
506
506
  current.started.add(threadId)
507
507
  if (waits) current.waiting.add(threadId)
508
- post(bot, { channelId: current.channelId, text: asSubtext(`⬦ ${started} <#${threadId}>`) })
508
+ post(bot, { channelId: current.channelId, text: asSubtext(`${started} <#${threadId}>`) })
509
509
  }
510
510
  : undefined
511
511
  const run = runShell(bot, { call: current, command: input.command, timeoutMs, onLine }).then((result) => {
@@ -668,8 +668,8 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
668
668
  }
669
669
  listen(bot, call)
670
670
  logger.info(`voice call started in ${channelId}, voice ${settings.voice}`)
671
- post(bot, { channelId, text: `-# ⬦ voice call started ⋅ gpt-live ⋅ ${settings.voice}` })
672
- if (settings.voiceNotice) post(bot, { channelId, text: asSubtext(`⬦ ${settings.voiceNotice}`) })
671
+ post(bot, { channelId, text: `-# voice call started ⋅ gpt-live ⋅ ${settings.voice}` })
672
+ if (settings.voiceNotice) post(bot, { channelId, text: asSubtext(settings.voiceNotice) })
673
673
  live.append({ text: 'The call started. Greet the users by name in one short sentence.', channel: 'speakable' })
674
674
  }
675
675
 
@@ -684,7 +684,7 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
684
684
  call.speaker.stop()
685
685
  if (call.connection.state.status !== VoiceConnectionStatus.Destroyed) call.connection.destroy()
686
686
  logger.info(`voice call ended in ${call.channelId}${reason ? `: ${reason}` : ''}`)
687
- post(bot, { channelId: call.channelId, text: `-# ⬦ voice call ended${reason ? `: ${reason}` : ''}` })
687
+ post(bot, { channelId: call.channelId, text: `-# voice call ended${reason ? `: ${reason}` : ''}` })
688
688
  }
689
689
 
690
690
  async function onVoiceState(bot: Bot, { before, after }: { before: VoiceState; after: VoiceState }) {
@@ -727,7 +727,7 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
727
727
  if (!call || call.ending) return
728
728
  const url = `https://discord.com/channels/${thread.guildId}/${threadId}`
729
729
  const state = error === null ? 'finished' : 'failed'
730
- post(bot, { channelId: call.channelId, text: asSubtext(`⬦ thread ${state}: ${thread.name} ⋅ ${url} ⋅ ${sessionId}`) })
730
+ post(bot, { channelId: call.channelId, text: asSubtext(`thread ${state}: ${thread.name} ⋅ ${url} ⋅ ${sessionId}`) })
731
731
  const failure = error === null ? '' : ` Error: ${error}`
732
732
  const notice = `<system>Thread ${state}: "${thread.name}". Thread ID ${threadId}, session ID ${sessionId}, URL ${url}.${failure}</system>`
733
733
  // Answer only for threads started in this call. A pending kimaki send --wait brings the transcript next.