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
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // root text, tool lines, banner, footer, retries, errors
14
14
  // child foreground: tool lines labelled "┣ general ⋅ glob ..."
15
- // background: no tool lines, one "⬦ general finished: ..." line
15
+ // background: no tool lines, one "general finished: ..." line
16
16
  // never text, banner or footer
17
17
  //
18
18
  // busy = root execution running OR any child running (29.2 #3). Typing
@@ -36,9 +36,12 @@ import {
36
36
  executeCalls,
37
37
  formatExecuteFailures,
38
38
  buttonRow,
39
+ formatAgentSwitched,
39
40
  formatBanner,
40
41
  formatCacheMiss,
42
+ formatCompacted,
41
43
  formatError,
44
+ formatModelSwitched,
42
45
  formatRetry,
43
46
  formatShellEnded,
44
47
  formatShellFinished,
@@ -52,10 +55,10 @@ import {
52
55
  type ToolInput,
53
56
  type UiMessage,
54
57
  } from './format-parts.ts'
58
+ import { IDLE_REPLY } from './plugin/coordinator.ts'
55
59
  import { closePermission, hydratePermissions, showPermission, STATUS, type PendingPermission } from './permissions.ts'
56
60
  import { closeForm, formatAnswer, hydrateForms, showForm, withAnswer, type PendingForm } from './questions.ts'
57
61
  import { cancelQueued, deliverQueued, enqueueInput, hydrateQueue, promoteQueued, queuedItems, type PendingInput } from './queue.ts'
58
- import { MESSAGE_TOOL } from './system-prompt.ts'
59
62
 
60
63
  export type Turn = {
61
64
  startedAt: number
@@ -65,14 +68,10 @@ export type Turn = {
65
68
  tokens: number
66
69
  // `kimaki sleep` ran in this turn: the session wakes later, so the footer is silent.
67
70
  slept: boolean
68
- // Message mode (Prefs.messageTool): a send_message call was shown in this turn.
69
- messaged: boolean
70
- // Message mode: the last hidden text, shown at the end if the turn sent no message.
71
- unsent: string | null
72
- }
73
-
74
- function newTurn(at: number): Turn {
75
- return { startedAt: at, model: null, agent: null, tokens: 0, slept: false, messaged: false, unsent: null }
71
+ // Discord message the next answer text replies to (a coordinator prompt, queue.ts). Taken by the first text.
72
+ replyTo: string | null
73
+ // The current answer posted model text. A coordinator answer without it gets no footer.
74
+ texted: boolean
76
75
  }
77
76
 
78
77
  export type Child = {
@@ -104,6 +103,8 @@ export type ThreadView = {
104
103
  sessionId: string
105
104
  // Project channel of the thread: its verbosity applies.
106
105
  channelId: string
106
+ // The view is a coordinator channel itself, not a thread.
107
+ coordinator: boolean
107
108
  // Current working directory of the root session (session.moved changes it).
108
109
  directory: string
109
110
  // True only for sessions this bot created: the first step shows a banner.
@@ -116,16 +117,19 @@ export type ThreadView = {
116
117
  children: Readonly<Record<string, Child>>
117
118
  // Background shells started by tool calls: shell ID -> label, until their job ends.
118
119
  shells: Readonly<Record<string, string>>
119
- // Blank line between text and tool blocks.
120
+ // Blank line between text and tool blocks. null: the current answer posted nothing yet (no footer).
120
121
  lastKind: 'text' | 'tool' | null
121
122
  lastRetryAt: number | null
122
123
  // Prompt tokens the provider holds in cache after the last root step, and when that step ended.
123
124
  cached: { tokens: number; at: number } | null
125
+ // The root context was compacted: the next root step (or the execution end) posts the new size.
126
+ compacted: boolean
124
127
  // Root inbox: user items not delivered yet, in order (queuedItems() for the queue).
125
128
  inbox: readonly PendingInput[]
126
129
  // Kimaki runs an OpenCode command (kimaki.command): the next root user item
127
130
  // without Discord metadata is its expanded prompt, shown as the typed command.
128
- commandPending: { text: string; username: string } | null
131
+ // `echo` is set when no Discord message shows the command (CLI, routines).
132
+ commandPending: { text: string; username: string; messageId: string | null; echo: string | null } | null
129
133
  // Questions and permission requests waiting for the user (root and children).
130
134
  forms: Readonly<Record<string, PendingForm>>
131
135
  permissions: Readonly<Record<string, PendingPermission>>
@@ -136,19 +140,22 @@ export type UploadFile = { path: string; name: string; size: number }
136
140
  export type Effect =
137
141
  // Bot-formatted Discord content (tool lines, banner, errors).
138
142
  // Effects are silent unless `notify` is set (format-parts.ts message flags).
139
- | { type: 'send'; text: string; notify?: boolean }
143
+ // `echo`: user text (a prompt echo), posted with mentions disabled.
144
+ | { type: 'send'; text: string; notify?: boolean; echo?: boolean }
140
145
  // Model markdown, rendered and split by the executor.
141
- | { type: 'markdown'; text: string; blankLineBefore: boolean }
146
+ // `replyTo`: the first message replies to this Discord message.
147
+ | { type: 'markdown'; text: string; blankLineBefore: boolean; replyTo: string | null }
142
148
  | { type: 'typing'; on: boolean }
143
149
  // Files from `kimaki upload-to-discord` (never from the reducer). `settle`
144
- // answers the waiting CLI call: null once posted, or why not.
145
- | { type: 'attachments'; files: readonly UploadFile[]; settle: (error: Error | null) => void }
146
- // The executor adds folder and git branch of `directory` when it posts.
147
- | { type: 'footer'; directory: string; durationMs: number; contextPercent: number | null; model: ModelRef; agent: string | null; notify: boolean }
148
- // Posts the messages in order; the first replies to `replyTo` when set.
149
- | { type: 'show'; key: string; messages: readonly UiMessage[]; replyTo: string | null; notify: boolean }
150
- // Edits the messages `show` posted under `key` (by index; the last one covers the rest).
151
- | { type: 'edit'; key: string; messages: readonly UiMessage[] }
150
+ // answers the waiting CLI call: the Discord URL of each file once posted, or why not.
151
+ | { type: 'attachments'; files: readonly UploadFile[]; settle: (result: Error | string[]) => void }
152
+ // The executor adds the git branch of `directory` and the defaults of `channelId` when it posts.
153
+ | { type: 'footer'; channelId: string; directory: string; durationMs: number; contextPercent: number | null; model: ModelRef; agent: string | null; notify: boolean }
154
+ // Posts the messages in order; the first replies to `replyTo` when set. `echo`: holds user text, no mentions.
155
+ | { type: 'show'; key: string; messages: readonly UiMessage[]; replyTo: string | null; notify: boolean; echo?: boolean }
156
+ // Edits the messages `show` posted under `key` (by index; the last one covers the rest),
157
+ // then posts `reply` as a reply to `replyTo`, or to the first of them when null.
158
+ | { type: 'edit'; key: string; messages: readonly UiMessage[]; reply?: string; replyTo?: string | null }
152
159
  // Marks the root execution that went idle at `idle` as read in OpenCode, once
153
160
  // the lines before it are posted. Bots cannot see Discord read state.
154
161
  | { type: 'viewed'; sessionId: string; idle: number }
@@ -174,7 +181,7 @@ export type KimakiEvent =
174
181
  // Kimaki is about to run an OpenCode command (prompt.ts). Prompts without
175
182
  // Discord metadata are echoed as sent from elsewhere; this one must not be.
176
183
  // TODO: pass metadata.discord to session.command once its payload accepts metadata, then drop this event.
177
- | { type: 'kimaki.command'; text: string; username: string }
184
+ | { type: 'kimaki.command'; text: string; username: string; messageId: string | null; echo: string | null }
178
185
  // session.command failed, so no prompt follows.
179
186
  | { type: 'kimaki.command-failed' }
180
187
  // A session found by walking parentID after a bot restart.
@@ -193,14 +200,13 @@ export function isKimakiEvent(event: ThreadEvent): event is KimakiEvent {
193
200
 
194
201
  export type Prefs = {
195
202
  verbosity: Verbosity
196
- // "providerID/modelID" -> context window size in tokens.
197
- contextLimits: Readonly<Record<string, number>>
198
- // `kimaki --message-tool`: Discord hides root plain text (kept for the TUI), only send_message shows.
199
- messageTool: boolean
203
+ // "providerID/modelID" -> display name and context window size in tokens.
204
+ models: Readonly<Record<string, { name: string; context: number }>>
200
205
  }
201
206
 
202
207
  const RETRY_NOTICE_INTERVAL_MS = 10_000
203
208
  const CACHE_MISS_SLACK = 1_024
209
+ const CACHE_MISS_MIN_WRITE_SHARE = 0.5
204
210
  const REPLAY_LIMIT = 30
205
211
 
206
212
  export function emptyView({
@@ -208,15 +214,18 @@ export function emptyView({
208
214
  channelId,
209
215
  directory,
210
216
  isNew,
217
+ coordinator,
211
218
  }: {
212
219
  sessionId: string
213
220
  channelId: string
214
221
  directory: string
215
222
  isNew: boolean
223
+ coordinator: boolean
216
224
  }): ThreadView {
217
225
  return {
218
226
  sessionId,
219
227
  channelId,
228
+ coordinator,
220
229
  directory,
221
230
  bannerPending: isNew,
222
231
  turn: null,
@@ -227,6 +236,7 @@ export function emptyView({
227
236
  lastKind: null,
228
237
  lastRetryAt: null,
229
238
  cached: null,
239
+ compacted: false,
230
240
  inbox: [],
231
241
  commandPending: null,
232
242
  forms: {},
@@ -234,13 +244,37 @@ export function emptyView({
234
244
  }
235
245
  }
236
246
 
247
+ function newTurn(startedAt: number): Turn {
248
+ return { startedAt, model: null, agent: null, tokens: 0, slept: false, replyTo: null, texted: false }
249
+ }
250
+
237
251
  export function isBusy(view: ThreadView): boolean {
238
252
  return view.turn !== null || Object.values(view.children).some((child) => child.running)
239
253
  }
240
254
 
255
+ function waitsForUser(view: ThreadView): boolean {
256
+ return Object.keys(view.forms).length > 0 || Object.keys(view.permissions).length > 0
257
+ }
258
+
241
259
  // Typing stops while the agent waits for the user (spec 6.6).
242
260
  function isTyping(view: ThreadView): boolean {
243
- return isBusy(view) && Object.keys(view.forms).length === 0 && Object.keys(view.permissions).length === 0
261
+ return isBusy(view) && !waitsForUser(view)
262
+ }
263
+
264
+ // Typing of one conversation across all views: a coordinator (a view whose channel
265
+ // is its own conversation) also types while a thread of its channel does, so
266
+ // background workers show there. Other conversations: isTyping of their view.
267
+ export function conversationTyping({ threads, threadId }: { threads: Readonly<Record<string, ThreadView>>; threadId: string }): boolean {
268
+ const view = threads[threadId]
269
+ if (!view || waitsForUser(view)) return false
270
+ if (isBusy(view)) return true
271
+ if (!view.coordinator) return false
272
+ return workersTyping({ threads, channelId: threadId })
273
+ }
274
+
275
+ // A thread of the channel runs and does not wait for the user.
276
+ export function workersTyping({ threads, channelId }: { threads: Readonly<Record<string, ThreadView>>; channelId: string }): boolean {
277
+ return Object.entries(threads).some(([id, thread]) => id !== channelId && thread.channelId === channelId && isTyping(thread))
244
278
  }
245
279
 
246
280
  export function eventSessionId(event: V2Event): string | null {
@@ -283,27 +317,41 @@ function toolLine({ draft, emit }: Context, text: string) {
283
317
  draft.lastKind = 'tool'
284
318
  }
285
319
 
286
- function contextPercent({ turn, prefs }: { turn: Draft<Turn>; prefs: Prefs }): number | null {
287
- if (!turn.model || turn.tokens <= 0) return null
288
- const limit = prefs.contextLimits[`${turn.model.providerID}/${turn.model.id}`]
320
+ function showCompacted(context: Context, size: { tokens: number | null; percent: number | null }) {
321
+ context.draft.compacted = false
322
+ toolLine(context, formatCompacted(size))
323
+ }
324
+
325
+ // A compaction in an execution with no model step (`/compact`, the idle coordinator compaction) stays silent:
326
+ // the user did not ask for anything, so the line is only noise. Auto compaction in a turn shows.
327
+ function endCompaction(context: Context) {
328
+ if (context.draft.compacted && context.draft.turn?.model) showCompacted(context, { tokens: null, percent: null })
329
+ context.draft.compacted = false
330
+ }
331
+
332
+ function contextPercent({ tokens, model, prefs }: { tokens: number; model: ModelRef | null; prefs: Prefs }): number | null {
333
+ if (!model || tokens <= 0) return null
334
+ const limit = prefs.models[`${model.providerID}/${model.id}`]?.context
289
335
  if (!limit || limit <= 0) return null
290
- return Math.round((turn.tokens / limit) * 100)
336
+ return Math.round((tokens / limit) * 100)
291
337
  }
292
338
 
293
339
  // The footer closing the answer of `turn` at `at`.
294
340
  function emitFooter({ draft, prefs, emit, turn, at, notify }: Context & { turn: Draft<Turn>; at: number; notify: boolean }) {
295
- // Message mode: a model that never called send_message would leave the user in silence.
296
- if (!turn.messaged && turn.unsent) emit({ type: 'markdown', text: turn.unsent, blankLineBefore: draft.lastKind === 'tool' })
297
- turn.unsent = null
298
341
  // No model step ran (a compaction-only execution): nothing to summarize.
299
342
  if (!turn.model) return
343
+ // The answer posted no text or tool line (e.g. an IDLE_REPLY, only hidden tools).
344
+ if (draft.lastKind === null) return
345
+ // A coordinator answer with only tool lines (it started a worker, then answered IDLE): its thread shows the work.
346
+ if (draft.coordinator && !turn.texted) return
300
347
  // A child still runs: the answer comes later.
301
348
  if (Object.values(draft.children).some((child) => child.running)) return
302
349
  emit({
303
350
  type: 'footer',
351
+ channelId: draft.channelId,
304
352
  directory: draft.directory,
305
353
  durationMs: at - turn.startedAt,
306
- contextPercent: contextPercent({ turn, prefs }),
354
+ contextPercent: contextPercent({ tokens: turn.tokens, model: turn.model, prefs }),
307
355
  model: { providerID: turn.model.providerID, id: turn.model.id },
308
356
  agent: turn.agent,
309
357
  notify,
@@ -334,7 +382,8 @@ function applyOpencode({ draft, event, prefs, emit }: Context & { event: V2Event
334
382
  // Questions and permissions of subagents show in this thread too.
335
383
  const label = child?.agent ?? null
336
384
  if (event.type === 'session.inbox.enqueued' && event.data.item.type === 'synthetic' && !child?.background) {
337
- return showShellFinished({ draft, prefs, emit, metadata: event.data.item.payload.metadata ?? {}, label })
385
+ const metadata = event.data.item.payload.metadata ?? {}
386
+ return showShellFinished({ draft, prefs, emit, metadata, label })
338
387
  }
339
388
  switch (event.type) {
340
389
  case 'form.created':
@@ -362,12 +411,26 @@ function applyRoot(context: Context & { event: V2Event }) {
362
411
  case 'session.execution.started':
363
412
  draft.turn ??= newTurn(event.created)
364
413
  return
414
+ // A new session's banner already shows its first model and agent.
415
+ case 'session.model.selected': {
416
+ const { model, previous } = event.data
417
+ const same = previous && previous.providerID === model.providerID && previous.id === model.id && previous.variant === model.variant
418
+ if (draft.bannerPending || same) return
419
+ emit({ type: 'send', text: formatModelSwitched({ name: prefs.models[`${model.providerID}/${model.id}`]?.name ?? `${model.providerID}/${model.id}`, variant: model.variant }) })
420
+ return
421
+ }
422
+ case 'session.agent.selected': {
423
+ const { agent, previous } = event.data
424
+ if (draft.bannerPending || agent === previous) return
425
+ emit({ type: 'send', text: formatAgentSwitched({ agent, previous }) })
426
+ return
427
+ }
365
428
  case 'session.step.started': {
366
429
  const model = { providerID: event.data.model.providerID, id: event.data.model.id }
367
430
  draft.turn ??= newTurn(event.created)
368
431
  draft.turn.model = model
369
432
  draft.turn.agent = event.data.agent
370
- if (draft.bannerPending) emit({ type: 'send', text: formatBanner({ model: event.data.model, agent: event.data.agent }) })
433
+ if (draft.bannerPending) emit({ type: 'send', text: formatBanner({ model: event.data.model, agent: event.data.agent, coordinator: draft.coordinator }) })
371
434
  draft.bannerPending = false
372
435
  return
373
436
  }
@@ -375,10 +438,17 @@ function applyRoot(context: Context & { event: V2Event }) {
375
438
  if (!draft.turn) return
376
439
  const { tokens } = event.data
377
440
  draft.turn.tokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
441
+ if (draft.compacted) {
442
+ const prompt = tokens.input + tokens.cache.read + tokens.cache.write
443
+ showCompacted(context, { tokens: prompt, percent: contextPercent({ tokens: prompt, model: draft.turn.model, prefs }) })
444
+ }
378
445
  // The prompt only grows, so the cached prefix should too. A drop means the provider
379
446
  // re-read it: changed prefix, other model, or cache expired (TTL). The slack absorbs cache block rounding.
447
+ // Only a step that rewrote most of the prompt is a full loss; a smaller rewrite is a partial miss, not worth a line.
380
448
  const previous = draft.cached
381
- if (previous && tokens.cache.read + CACHE_MISS_SLACK < previous.tokens) {
449
+ const promptTokens = tokens.input + tokens.cache.read + tokens.cache.write
450
+ const rewroteMost = tokens.cache.write >= promptTokens * CACHE_MISS_MIN_WRITE_SHARE
451
+ if (previous && rewroteMost && tokens.cache.read + CACHE_MISS_SLACK < previous.tokens) {
382
452
  toolLine(context, formatCacheMiss({ read: tokens.cache.read, expected: previous.tokens, idleMs: event.created - previous.at }))
383
453
  }
384
454
  draft.cached = { tokens: tokens.cache.read + tokens.cache.write, at: event.created }
@@ -386,12 +456,15 @@ function applyRoot(context: Context & { event: V2Event }) {
386
456
  }
387
457
  case 'session.text.ended': {
388
458
  const text = event.data.text.trim()
389
- if (!text) return
390
- if (prefs.messageTool && draft.turn) {
391
- draft.turn.unsent = text
392
- return
459
+ // A coordinator turn with nothing for the user (a note, a started worker) answers IDLE_REPLY: Discord shows nothing.
460
+ if (!text || text === IDLE_REPLY) return
461
+ const replyTo = draft.turn?.replyTo ?? null
462
+ // The reply header already sets the text apart from the tool lines above.
463
+ emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' && !replyTo, replyTo })
464
+ if (draft.turn) {
465
+ draft.turn.replyTo = null
466
+ draft.turn.texted = true
393
467
  }
394
- emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
395
468
  draft.lastKind = 'text'
396
469
  return
397
470
  }
@@ -419,24 +492,28 @@ function applyRoot(context: Context & { event: V2Event }) {
419
492
  case 'session.compaction.ended':
420
493
  // A compacted history has a new prefix: its first miss is expected.
421
494
  draft.cached = null
422
- return toolLine(context, asSubtext('⬦ context compacted'))
495
+ // Its size is only known when the next step reads it.
496
+ draft.compacted = true
497
+ return
423
498
  case 'session.compaction.failed':
424
499
  return toolLine(context, formatError(`compaction failed: ${event.data.error.message}`))
425
500
  case 'session.execution.succeeded': {
426
- const turn = draft.turn
501
+ endCompaction(context)
502
+ if (draft.turn) emitFooter({ ...context, turn: draft.turn, at: event.created, notify: !draft.turn.slept })
427
503
  draft.turn = null
428
504
  draft.lastKind = null
429
- if (turn) emitFooter({ ...context, turn, at: event.created, notify: !turn.slept })
430
505
  emit({ type: 'viewed', sessionId: draft.sessionId, idle: event.created })
431
506
  return
432
507
  }
433
508
  case 'session.execution.failed':
509
+ endCompaction(context)
434
510
  draft.turn = null
435
511
  draft.lastKind = null
436
512
  emit({ type: 'send', text: formatError(event.data.error.message), notify: true })
437
513
  emit({ type: 'viewed', sessionId: draft.sessionId, idle: event.created })
438
514
  return
439
515
  case 'session.execution.interrupted':
516
+ endCompaction(context)
440
517
  draft.turn = null
441
518
  draft.lastKind = null
442
519
  // A shutdown does not make the session idle in OpenCode.
@@ -447,12 +524,16 @@ function applyRoot(context: Context & { event: V2Event }) {
447
524
  case 'session.inbox.delivered': {
448
525
  // OpenCode runs queued items in the same execution: close the answer
449
526
  // before each one with a silent footer, like V1 did per turn.
450
- const queued = queuedItems(draft).some((item) => item.inboxID === event.data.inboxID)
527
+ const item = draft.inbox.find((candidate) => candidate.inboxID === event.data.inboxID)
528
+ const queued = item?.delivery === 'queue'
529
+ // Each user prompt the run takes gets its own reply; synthetic items keep the current one.
530
+ // An acked item's "Queued message sent" reply already marks where its answer starts.
531
+ if (item && draft.turn) draft.turn.replyTo = item.acked ? null : item.replyTo
451
532
  if (queued && draft.turn?.model) {
452
533
  emitFooter({ ...context, turn: draft.turn, at: event.created, notify: false })
453
534
  draft.turn.startedAt = event.created
454
535
  draft.turn.slept = false
455
- draft.turn.messaged = false
536
+ draft.turn.texted = false
456
537
  draft.lastKind = null
457
538
  }
458
539
  return deliverQueued({ draft, emit, inboxID: event.data.inboxID })
@@ -513,15 +594,6 @@ function applyTool(context: Context & { event: V2Event; label: string | undefine
513
594
  if (reused) Object.assign(reused, { ...call, agent: call.agent || reused.agent })
514
595
  }
515
596
  draft.tools[key] = tool
516
- // The plugin offers send_message to root sessions only (MESSAGE_TOOL).
517
- if (name === MESSAGE_TOOL && label === undefined) {
518
- const text = stringInput(input, 'text').trim()
519
- if (draft.turn) draft.turn.messaged = true
520
- if (!text) return
521
- context.emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
522
- draft.lastKind = 'text'
523
- return
524
- }
525
597
  if (render && isToolVisible({ name, input }, prefs.verbosity)) toolLine(context, formatToolLine({ name, input }, { label }))
526
598
  return
527
599
  }
@@ -585,6 +657,7 @@ function showShellFinished({
585
657
  ...context
586
658
  }: Context & { metadata: Readonly<Record<string, unknown>>; label: string | null }) {
587
659
  const { shellID, jobID, state, exit } = metadata
660
+ // A <session-finished> note (coordinator.ts) shows nothing: the coordinator reports the outcome.
588
661
  if (metadata['source'] !== 'shell' || typeof shellID !== 'string' || typeof jobID !== 'string') return
589
662
  const description = context.draft.shells[shellID] ?? null
590
663
  delete context.draft.shells[shellID]
@@ -609,7 +682,7 @@ function showAgentPrompt({ draft, emit, prompt }: Pick<Context, 'draft' | 'emit'
609
682
  function dismissAgentPrompts({ draft, emit, ids, result = 'dismissed' }: Pick<Context, 'draft' | 'emit'> & { ids: readonly string[]; result?: string }) {
610
683
  if (ids.length === 0) return
611
684
  draft.agentUi = draft.agentUi.filter((prompt) => !ids.includes(prompt.id))
612
- for (const id of ids) emit({ type: 'edit', key: `agent:${id}`, messages: [textOnly(asSubtext(`⬦ ${result}`))] })
685
+ for (const id of ids) emit({ type: 'edit', key: `agent:${id}`, messages: [textOnly(asSubtext(result))] })
613
686
  }
614
687
 
615
688
  function applyKimaki({ draft, event, prefs, emit }: Context & { event: KimakiEvent }) {
@@ -622,7 +695,7 @@ function applyKimaki({ draft, event, prefs, emit }: Context & { event: KimakiEve
622
695
  if (draft.turn) draft.turn.slept = true
623
696
  return
624
697
  case 'kimaki.command':
625
- draft.commandPending = { text: event.text, username: event.username }
698
+ draft.commandPending = { text: event.text, username: event.username, messageId: event.messageId, echo: event.echo }
626
699
  return
627
700
  case 'kimaki.command-failed':
628
701
  draft.commandPending = null
@@ -673,13 +746,9 @@ export function replayEffects({
673
746
  const blocks = messages.flatMap((message): Block[] => {
674
747
  if (message.type !== 'assistant') return []
675
748
  return message.content.flatMap((part): Block[] => {
676
- if (part.type === 'text') return part.text.trim() && !prefs.messageTool ? [{ kind: 'text', text: part.text.trim() }] : []
749
+ if (part.type === 'text') return part.text.trim() ? [{ kind: 'text', text: part.text.trim() }] : []
677
750
  if (part.type !== 'tool' || part.state.status === 'streaming') return []
678
751
  const { input, metadata } = part.state
679
- if (part.name === MESSAGE_TOOL) {
680
- const text = stringInput(input, 'text').trim()
681
- return text ? [{ kind: 'text', text }] : []
682
- }
683
752
  const isExecute = part.name === 'execute'
684
753
  const lines = [{ name: part.name, input }, ...(isExecute ? executeCalls(metadata) : [])]
685
754
  .filter((call) => isToolVisible(call, prefs.verbosity))
@@ -692,7 +761,7 @@ export function replayEffects({
692
761
  const skipped = blocks.length - kept.length
693
762
  const effects = kept.map((block, index): Effect => {
694
763
  const previous = kept[index - 1]?.kind ?? null
695
- if (block.kind === 'text') return { type: 'markdown', text: block.text, blankLineBefore: previous === 'tool' }
764
+ if (block.kind === 'text') return { type: 'markdown', text: block.text, blankLineBefore: previous === 'tool', replyTo: null }
696
765
  return { type: 'send', text: previous === 'text' ? `\n${block.text}` : block.text }
697
766
  })
698
767
  return [
@@ -1,5 +1,10 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { createServer } from 'vite'
1
6
  import { expect, test } from 'vitest'
2
- import { detectPortFromText, routeName } from './tunnel.ts'
7
+ import { detectPortFromText, parseNetstatListeningPids, routeName, startHostProxy } from './tunnel.ts'
3
8
 
4
9
  test('detects the dev server port and skips noise lines', () => {
5
10
  const vite = '\u001b[33mPort 5173 is in use, trying another one...\u001b[39m\n ➜ Local: \u001b[36mhttp://localhost:\u001b[1m5174\u001b[22m/\u001b[39m\n'
@@ -25,15 +30,86 @@ test('detects the dev server port and skips noise lines', () => {
25
30
  test('route name: explicit, then tuistory session, then random', () => {
26
31
  expect({
27
32
  explicit: routeName({ name: 'docs', tuistorySessionId: 'abc123' }),
28
- invalid: String(routeName({ name: 'Bad_Name' })),
29
- tuistory: routeName({ tuistorySessionId: 'ABC123' }),
33
+ normalized: routeName({ name: '_My_App.dev_' }),
34
+ invalid: String(routeName({ name: '___' })),
35
+ tuistory: routeName({ tuistorySessionId: 'Holocron_Dev' }),
36
+ tuistoryInvalid: String(routeName({ tuistorySessionId: '___' })).length,
30
37
  random: String(routeName({})).length,
31
38
  }).toMatchInlineSnapshot(`
32
39
  {
33
40
  "explicit": "docs",
34
- "invalid": "Error: Invalid tunnel ID "Bad_Name": use lowercase letters, digits and dashes",
41
+ "invalid": "Error: Invalid tunnel ID "___": use letters, digits and dashes",
42
+ "normalized": "my-app-dev",
35
43
  "random": 16,
36
- "tuistory": "abc123",
44
+ "tuistory": "holocron-dev",
45
+ "tuistoryInvalid": 16,
37
46
  }
38
47
  `)
39
48
  })
49
+
50
+ test('netstat listening pids on Windows', () => {
51
+ const stdout = [
52
+ 'Active Connections',
53
+ ' Proto Local Address Foreign Address State PID',
54
+ ' TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234',
55
+ ' TCP [::]:5173 [::]:0 LISTENING 1234',
56
+ ' TCP 127.0.0.1:51730 0.0.0.0:0 LISTENING 999',
57
+ ' TCP 127.0.0.1:5173 127.0.0.1:60000 ESTABLISHED 777',
58
+ ' UDP 0.0.0.0:5173 *:* 555',
59
+ ].join('\r\n')
60
+ expect(parseNetstatListeningPids({ stdout, port: 5173 })).toMatchInlineSnapshot(`
61
+ [
62
+ 1234,
63
+ ]
64
+ `)
65
+ })
66
+
67
+ function request({ port, headers }: { port: number; headers: http.OutgoingHttpHeaders }): Promise<string> {
68
+ return new Promise((resolve, reject) => {
69
+ const req = http.request({ host: '127.0.0.1', port, path: '/', headers })
70
+ req.on('response', (res) => {
71
+ res.resume()
72
+ resolve(String(res.statusCode))
73
+ req.destroy()
74
+ })
75
+ req.on('upgrade', (res, socket) => {
76
+ resolve(`${res.statusCode} ${res.headers['sec-websocket-protocol']}`)
77
+ socket.destroy()
78
+ })
79
+ req.on('error', reject)
80
+ req.end()
81
+ })
82
+ }
83
+
84
+ test('host proxy lets the public tunnel host through Vite host check, HMR included', async () => {
85
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-tunnel-'))
86
+ fs.writeFileSync(path.join(root, 'index.html'), '<p>hi</p>')
87
+ const vite = await createServer({ root, configFile: false, logLevel: 'silent', server: { host: '127.0.0.1', port: 0 } })
88
+ await vite.listen()
89
+ const address = vite.httpServer!.address()
90
+ if (!address || typeof address === 'string') throw new Error('no vite address')
91
+ const proxy = await startHostProxy({ host: '127.0.0.1', port: address.port })
92
+ if (proxy instanceof Error) throw proxy
93
+ const proxyAddress = proxy.address()
94
+ if (!proxyAddress || typeof proxyAddress === 'string') throw new Error('no proxy address')
95
+
96
+ const publicHost = { host: 'docs.abc.opentunnel.xyz' }
97
+ const hmr = { ...publicHost, connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-protocol': 'vite-hmr' }
98
+ try {
99
+ expect({
100
+ direct: await request({ port: address.port, headers: publicHost }),
101
+ proxied: await request({ port: proxyAddress.port, headers: publicHost }),
102
+ proxiedHmr: await request({ port: proxyAddress.port, headers: hmr }),
103
+ }).toMatchInlineSnapshot(`
104
+ {
105
+ "direct": "403",
106
+ "proxied": "200",
107
+ "proxiedHmr": "101 vite-hmr",
108
+ }
109
+ `)
110
+ } finally {
111
+ proxy.close()
112
+ await vite.close()
113
+ fs.rmSync(root, { recursive: true, force: true })
114
+ }
115
+ })