@kabmo-labs/kabmo 0.7.0 → 0.9.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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `claimPort` — the bind decision, and its whole value is in the failure path:
8
+ * a starter that loses must fail with a message that names the real situation
9
+ * instead of a bare `EADDRINUSE`.
10
+ *
11
+ * The cases drive real listeners on OS-assigned ports, because the question is
12
+ * about sockets. One of them is the reported bug: `localhost` stands for two
13
+ * addresses, the guard asked about only the one Bun would bind, and a stale
14
+ * instance kept the other — two servers on one port, and a browser landing on
15
+ * whichever family it resolved.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { testPosix } from '@/test-support/platform';
23
+
24
+ import { claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
25
+ import { createLoopbackHarness, ipv6Loopback, realFetch } from '@/test-support/loopback';
26
+
27
+ const harness = createLoopbackHarness();
28
+ const { occupy, occupyAt, freePort, freePortBothFamilies, livePid, spawnChamberHolder } = harness;
29
+
30
+ let tempDir: string;
31
+ let originalDataDir: string | undefined;
32
+
33
+ beforeEach(() => {
34
+ // The instance record and the port lock both live under the data directory;
35
+ // pointing it at a temp dir keeps a test from reading (or writing) the real
36
+ // `~/.kabmo/run`.
37
+ originalDataDir = Bun.env.KABMO_DATA_DIR;
38
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-test-claim-port-'));
39
+ Bun.env.KABMO_DATA_DIR = tempDir;
40
+ globalThis.fetch = realFetch;
41
+ });
42
+
43
+ afterEach(async () => {
44
+ await harness.dispose();
45
+ if (originalDataDir === undefined) delete Bun.env.KABMO_DATA_DIR;
46
+ else Bun.env.KABMO_DATA_DIR = originalDataDir;
47
+ fs.rmSync(tempDir, { recursive: true, force: true });
48
+ });
49
+
50
+ describe('claimPort', () => {
51
+ test('binds a free port through the injected listen', async () => {
52
+ const port = await freePort();
53
+ let binds = 0;
54
+
55
+ await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
56
+
57
+ expect(binds).toBe(1);
58
+ });
59
+
60
+ test('re-binds when the occupant is this very process', async () => {
61
+ // `bun run --hot` re-evaluates the entry inside the running process, so the
62
+ // port is held by us and Bun swaps the handler on the next listen. Killing
63
+ // the dev loop on every save is the failure this prevents.
64
+ const { port } = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
65
+ let binds = 0;
66
+
67
+ await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
68
+
69
+ expect(binds).toBe(1);
70
+ });
71
+
72
+ testPosix('names a Kabmo occupant held by another process', async () => {
73
+ const holder = await spawnChamberHolder();
74
+ const { port } = occupy(() => Response.json({ service: 'kabmo', pid: holder.pid, version: '9.9.9' }));
75
+ let binds = 0;
76
+
77
+ const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
78
+ .then(() => null, (thrown: unknown) => thrown);
79
+
80
+ expect(error).toBeInstanceOf(PortInUseError);
81
+ const message = (error as Error).message;
82
+ expect(message).toContain(`Port ${port} is already served by Kabmo`);
83
+ expect(message).toContain(`pid ${holder.pid}`);
84
+ expect(message).toContain('v9.9.9');
85
+ expect(message).toContain('Nothing was stopped');
86
+ // A starting instance never binds over an identified one.
87
+ expect(binds).toBe(0);
88
+ });
89
+
90
+ test('names a foreign listener as not Kabmo, without a bind error', async () => {
91
+ // Nothing answers `/api/health` and nothing identifies itself, so the probe
92
+ // fails first and `listen` is never attempted.
93
+ const { port } = occupy(() => new Response('not kabmo'));
94
+ let binds = 0;
95
+
96
+ const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
97
+ .then(() => null, (thrown: unknown) => thrown);
98
+
99
+ expect(error).toBeInstanceOf(PortInUseError);
100
+ const message = (error as Error).message;
101
+ expect(message).toContain(`Port ${port} is in use by another process`);
102
+ expect(message).toContain(`lsof -nP -iTCP:${port} -sTCP:LISTEN`);
103
+ expect(message).not.toContain('Bind error');
104
+ expect(binds).toBe(0);
105
+ });
106
+
107
+ test.skipIf(!ipv6Loopback)('refuses a name whose OTHER address is held', async () => {
108
+ // The reported bug: a stale instance held `127.0.0.1:3000`, `bun run dev`
109
+ // probed only `localhost`'s other family (`::1`), found it free, and bound
110
+ // it. Two servers, one port, and a browser landing on whichever family it
111
+ // resolved — the stale one, serving a different data mode.
112
+ const port = await freePortBothFamilies();
113
+ // A listener reporting OUR pid is read as this process re-binding, so the
114
+ // occupant has to name another live process to be refused.
115
+ const holder = await livePid();
116
+ const held = occupyAt(port, '127.0.0.1', () => Response.json({ service: 'kabmo', pid: holder }));
117
+ let binds = 0;
118
+
119
+ const error = await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } })
120
+ .then(() => null, (thrown: unknown) => thrown);
121
+
122
+ expect(error).toBeInstanceOf(PortInUseError);
123
+ const message = (error as Error).message;
124
+ expect(message).toContain(`Port ${port} is already served by Kabmo`);
125
+ // The address clause is the actionable half: `lsof` lists both sockets, so
126
+ // a bare "port busy" does not say which one has to be freed.
127
+ expect(message).toContain('on 127.0.0.1');
128
+ expect(binds).toBe(0);
129
+
130
+ held.stop(true);
131
+ });
132
+
133
+ test.skipIf(!ipv6Loopback)('binds a name whose addresses are both free', async () => {
134
+ const port = await freePortBothFamilies();
135
+ let binds = 0;
136
+
137
+ await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } });
138
+
139
+ expect(binds).toBe(1);
140
+ });
141
+
142
+ test('carries the bind error when the port looked free', async () => {
143
+ // The probe is only a short-circuit; when `listen` fails anyway, the real
144
+ // reason must survive into the error rather than being replaced by a guess.
145
+ const port = await freePort();
146
+
147
+ const error = await claimPort({
148
+ port,
149
+ host: '127.0.0.1',
150
+ listen: async () => { throw new Error('EADDRINUSE: address already in use'); },
151
+ }).then(() => null, (thrown: unknown) => thrown);
152
+
153
+ expect(error).toBeInstanceOf(PortInUseError);
154
+ expect((error as Error).message).toContain('Bind error: EADDRINUSE: address already in use');
155
+ });
156
+ });
@@ -4,22 +4,18 @@
4
4
  */
5
5
 
6
6
  /**
7
- * `port-guard.ts` decides whether this process may take a port, and its whole
8
- * value is in the failure path: a starter that loses the race must fail with a
9
- * message that names the real situation instead of a bare `EADDRINUSE`. These
10
- * tests therefore drive both mechanisms the module can pick — the `flock(2)`
11
- * lease and the PID-file fallback — and both claim outcomes: the bind succeeds,
12
- * or the error text carries the occupant / the underlying bind error.
7
+ * `acquirePortLock` — the per-port startup lease, which exists so two starters
8
+ * racing on one free port fail with a message that names the real situation
9
+ * instead of a bare `EADDRINUSE`.
13
10
  *
14
- * The fallback is forced where its recovery logic is the subject (a dead
15
- * holder, a recycled PID): `flock` ignores the lock file's contents, so those
16
- * branches are otherwise unreachable on this host. Everything runs against
17
- * temp data directories and OS-assigned ports; the only subprocesses are
18
- * throwaway `bun -e` holders, never an `omp` server.
11
+ * Both mechanisms are driven: the `flock(2)` lease and the PID-file fallback.
12
+ * The fallback is FORCED where its recovery logic is the subject (a dead
13
+ * holder, a recycled PID) — `flock` keys on the inode and ignores the file's
14
+ * contents, so those branches are otherwise unreachable on this host. The claim
15
+ * itself is `claim-port`.
19
16
  */
20
17
 
21
18
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
22
- import type { Server } from 'bun';
23
19
  import fs from 'node:fs';
24
20
  import os from 'node:os';
25
21
  import path from 'node:path';
@@ -29,18 +25,15 @@ import { flockAvailable } from '@/server/lib/lifecycle/flock';
29
25
  import type { FlockHandle } from '@/server/lib/lifecycle/flock';
30
26
  import { getProcessState } from '@/server/lib/lifecycle/identity';
31
27
  import { getLockPath } from '@/server/lib/lifecycle/paths';
32
- import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
28
+ import { acquirePortLock } from '@/server/lib/lifecycle/port-guard';
33
29
  import type { PortLock } from '@/server/lib/lifecycle/port-guard';
34
- import { waitForPortFree } from '@/server/lib/lifecycle/probe';
30
+ import { createLoopbackHarness, realFetch } from '@/test-support/loopback';
31
+
32
+ const harness = createLoopbackHarness();
35
33
 
36
34
  let tempDir: string;
37
35
  let originalDataDir: string | undefined;
38
- /** The runner's own fetch, captured before any test runs. */
39
- /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
40
- const realFetch = Bun.fetch;
41
36
  const leases: (PortLock | FlockHandle)[] = [];
42
- const children: Bun.Subprocess[] = [];
43
- const servers: Server<undefined>[] = [];
44
37
 
45
38
  /** The flock singleton is anchored on `globalThis`; leaked state spans suites. */
46
39
  function flockHost(): { __kabmoFlock?: unknown } {
@@ -63,80 +56,17 @@ function track<T extends PortLock | FlockHandle>(lease: T | null): T | null {
63
56
  return lease;
64
57
  }
65
58
 
66
- /**
67
- * A real process whose argv says `kabmo`, i.e. `matched` to identity.ts.
68
- *
69
- * The child announces itself before it parks, and that line is what says the OS
70
- * has finished `exec`: a freshly spawned process's command line is unreadable
71
- * for the instant between fork and exec (`/proc/<pid>/cmdline` reads empty
72
- * there, so Linux's probe falls back to `comm` and identity answers
73
- * `mismatched` for a process that IS ours). Production reads a PID out of a
74
- * lock file written by a process started long before, so it never sees that
75
- * window — no fixed delay to guess at here.
76
- */
77
- async function spawnChamberHolder(): Promise<Bun.Subprocess> {
78
- const child = Bun.spawn({
79
- cmd: ['bun', '-e', 'console.log("ready"); await new Promise(() => {})', 'kabmo-holder'],
80
- stdout: 'pipe',
81
- stderr: 'ignore',
82
- });
83
- children.push(child);
84
- const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
85
- await reader.read();
86
- reader.releaseLock();
87
- expect(getProcessState(child.pid)).toBe('matched');
88
- return child;
89
- }
90
-
91
- /** A PID that has exited and been reaped, so it reads as `dead`. */
92
- async function deadPid(): Promise<number> {
93
- const child = Bun.spawn({ cmd: ['bun', '-e', 'process.exit(0)'], stdout: 'ignore', stderr: 'ignore' });
94
- const pid = child.pid;
95
- await child.exited;
96
- expect(getProcessState(pid)).toBe('dead');
97
- return pid;
98
- }
99
-
100
- /** An OS-assigned port, released and confirmed free before use. */
101
- async function freePort(): Promise<number> {
102
- const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('unused') });
103
- const port = server.port;
104
- if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
105
- server.stop(true);
106
- if (!(await waitForPortFree(port, '127.0.0.1', 2_000, 25))) throw new Error(`ephemeral port ${port} never became free`);
107
- return port;
108
- }
109
-
110
- /** A live listener on an OS-assigned port, held for the whole test. */
111
- /** A live listener on an OS-assigned port; the guard reads the assigned
112
- * number, never `server.port` (`number | undefined` in Bun's types). */
113
- function occupy(fetch: (request: Request) => Response): number {
114
- const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch });
115
- const port = server.port;
116
- if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
117
- servers.push(server);
118
- return port;
119
- }
120
-
121
59
  beforeEach(() => {
122
60
  originalDataDir = Bun.env.KABMO_DATA_DIR;
123
61
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-test-port-guard-'));
124
62
  Bun.env.KABMO_DATA_DIR = tempDir;
125
- // `claimPort` asks the occupant whether it is a Kabmo server, over the
126
- // global fetch. A stub another file left installed would answer for it.
63
+ // The guard asks the occupant whether it is a Kabmo server, over the global
64
+ // fetch. A stub another file left installed would answer for it.
127
65
  globalThis.fetch = realFetch;
128
66
  });
129
67
 
130
68
  afterEach(async () => {
131
- for (const child of children.splice(0)) {
132
- try {
133
- child.kill('SIGKILL');
134
- await child.exited;
135
- } catch {
136
- // Already gone.
137
- }
138
- }
139
- for (const server of servers.splice(0)) server.stop(true);
69
+ await harness.dispose();
140
70
  for (const lease of leases.splice(0)) lease.release();
141
71
  delete flockHost().__kabmoFlock;
142
72
  if (originalDataDir === undefined) delete Bun.env.KABMO_DATA_DIR;
@@ -176,7 +106,7 @@ describe('acquirePortLock', () => {
176
106
  test('reclaims a lock whose holder is dead', async () => {
177
107
  usePidFileFallback();
178
108
  const lockPath = getLockPath(4213);
179
- fs.writeFileSync(lockPath, String(await deadPid()));
109
+ fs.writeFileSync(lockPath, String(await harness.deadPid()));
180
110
 
181
111
  const reclaimed = track(await acquirePortLock(4213, 0));
182
112
 
@@ -194,17 +124,16 @@ describe('acquirePortLock', () => {
194
124
  usePidFileFallback();
195
125
  // A live PID that is not Kabmo means the recorded instance is gone;
196
126
  // refusing here would block startup on a free port forever.
197
- const stranger = Bun.spawn({ cmd: ['bun', '-e', 'await new Promise(() => {})'], stdout: 'ignore', stderr: 'ignore' });
198
- children.push(stranger);
199
- expect(getProcessState(stranger.pid)).toBe('mismatched');
200
- fs.writeFileSync(getLockPath(4214), String(stranger.pid));
127
+ const stranger = await harness.livePid();
128
+ expect(getProcessState(stranger)).toBe('mismatched');
129
+ fs.writeFileSync(getLockPath(4214), String(stranger));
201
130
 
202
131
  expect(track(await acquirePortLock(4214, 0))).not.toBeNull();
203
132
  });
204
133
 
205
134
  testPosix('refuses a lock held by a live Kabmo process', async () => {
206
135
  usePidFileFallback();
207
- const holder = await spawnChamberHolder();
136
+ const holder = await harness.spawnChamberHolder();
208
137
  const lockPath = getLockPath(4215);
209
138
  fs.writeFileSync(lockPath, String(holder.pid));
210
139
 
@@ -213,76 +142,3 @@ describe('acquirePortLock', () => {
213
142
  expect(fs.readFileSync(lockPath, 'utf8')).toBe(String(holder.pid));
214
143
  });
215
144
  });
216
-
217
- describe('claimPort', () => {
218
- test('binds a free port through the injected listen', async () => {
219
- const port = await freePort();
220
- let binds = 0;
221
-
222
- await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
223
-
224
- expect(binds).toBe(1);
225
- });
226
-
227
- test('re-binds when the occupant is this very process', async () => {
228
- // `bun run --hot` re-evaluates the entry inside the running process, so the
229
- // port is held by us and Bun swaps the handler on the next listen. Killing
230
- // the dev loop on every save is the failure this prevents.
231
- const port = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
232
- let binds = 0;
233
-
234
- await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
235
-
236
- expect(binds).toBe(1);
237
- });
238
-
239
- testPosix('names a Kabmo occupant held by another process', async () => {
240
- const holder = await spawnChamberHolder();
241
- const port = occupy(() => Response.json({ service: 'kabmo', pid: holder.pid, version: '9.9.9' }));
242
- let binds = 0;
243
-
244
- const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
245
- .then(() => null, (thrown: unknown) => thrown);
246
-
247
- expect(error).toBeInstanceOf(PortInUseError);
248
- const message = (error as Error).message;
249
- expect(message).toContain(`Port ${port} is already served by Kabmo`);
250
- expect(message).toContain(`pid ${holder.pid}`);
251
- expect(message).toContain('v9.9.9');
252
- expect(message).toContain('Nothing was stopped');
253
- // A starting instance never binds over an identified one.
254
- expect(binds).toBe(0);
255
- });
256
-
257
- test('names a foreign listener as not Kabmo, without a bind error', async () => {
258
- // Nothing answers `/api/health` and nothing identifies itself, so the probe
259
- // fails first and `listen` is never attempted.
260
- const port = occupy(() => new Response('not kabmo'));
261
- let binds = 0;
262
-
263
- const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
264
- .then(() => null, (thrown: unknown) => thrown);
265
-
266
- expect(error).toBeInstanceOf(PortInUseError);
267
- const message = (error as Error).message;
268
- expect(message).toContain(`Port ${port} is in use by another process`);
269
- expect(message).toContain(`lsof -nP -iTCP:${port} -sTCP:LISTEN`);
270
- expect(message).not.toContain('Bind error');
271
- expect(binds).toBe(0);
272
- });
273
-
274
- test('carries the bind error when the port looked free', async () => {
275
- // The probe is only a short-circuit; when `listen` fails anyway, the real
276
- // reason must survive into the error rather than being replaced by a guess.
277
- const port = await freePort();
278
-
279
- const error = await claimPort({
280
- port,
281
- host: '127.0.0.1',
282
- listen: async () => { throw new Error('EADDRINUSE: address already in use'); },
283
- }).then(() => null, (thrown: unknown) => thrown);
284
-
285
- expect(error).toBeInstanceOf(PortInUseError);
286
- expect((error as Error).message).toContain('Bind error: EADDRINUSE: address already in use');
287
- });
288
- });
@@ -18,6 +18,12 @@
18
18
  * receive connections. The newcomer would look healthy in its own log while the
19
19
  * browser kept talking to the old build; the guard stops that state from being
20
20
  * created in the first place.
21
+ *
22
+ * "One port" is every ADDRESS the bind host stands for. A name is not one
23
+ * socket: `localhost` is `127.0.0.1` and `::1`, and Bun binds the one it
24
+ * resolves first — so asking about that family alone let a stale instance keep
25
+ * the other, and the browser landed on whichever it resolved. See
26
+ * `probeAddresses`; the failure message names the address that is held.
21
27
  */
22
28
 
23
29
  import fs from 'fs';
@@ -26,7 +32,7 @@ import { flockAvailable, tryFlock } from '@/server/lib/lifecycle/flock';
26
32
  import { getProcessState } from '@/server/lib/lifecycle/identity';
27
33
  import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
28
34
  import { getLockPath } from '@/server/lib/lifecycle/paths';
29
- import { isPortAvailable } from '@/server/lib/lifecycle/probe';
35
+ import { probeAddresses, unavailableAddresses } from '@/server/lib/lifecycle/probe';
30
36
 
31
37
  const LOCK_WAIT_MS = 3_000;
32
38
  /** Grace period for a port that is settling (a server that just exited, or one that is starting). */
@@ -129,7 +135,13 @@ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promi
129
135
  let bindError: unknown = null;
130
136
 
131
137
  for (;;) {
132
- if (await isPortAvailable(port, host)) {
138
+ // Every address the host can land on, not just the first one it resolves
139
+ // to. `localhost` is two sockets, Bun binds one of them, and asking about
140
+ // only that one let a stale instance keep the other half of the port — two
141
+ // servers, one port, and a browser that reached whichever family it
142
+ // happened to resolve. See `probeAddresses`.
143
+ const blocked = await unavailableAddresses(port, host);
144
+ if (blocked.length === 0) {
133
145
  try {
134
146
  await listen();
135
147
  return;
@@ -148,9 +160,9 @@ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promi
148
160
  return;
149
161
  }
150
162
 
151
- if (occupant) throw await buildPortInUseError(port, host, bindError);
163
+ if (occupant) throw await buildPortInUseError(port, host, bindError, blocked);
152
164
 
153
- if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError);
165
+ if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError, blocked);
154
166
  await Bun.sleep(200);
155
167
  }
156
168
  }
@@ -164,13 +176,33 @@ function portGuidance(): string {
164
176
  ].join('\n');
165
177
  }
166
178
 
167
- async function buildPortInUseError(port: number, host: string, bindError: unknown): Promise<PortInUseError> {
179
+ /**
180
+ * The address clause, when the host is a name that stands for more than one.
181
+ *
182
+ * `Port 3000 is in use` is not actionable on a machine where `localhost` is two
183
+ * sockets: it names neither the one that is taken nor the one that is free, and
184
+ * `lsof` lists both. Naming it is the whole difference between "something is
185
+ * wrong" and "this address is held, that one is not".
186
+ */
187
+ function addressClause(host: string, blocked: string[]): string {
188
+ if (blocked.length === 0) return '';
189
+ if (probeAddresses(host).length < 2) return '';
190
+ return ` on ${blocked.join(', ')}`;
191
+ }
192
+
193
+ async function buildPortInUseError(
194
+ port: number,
195
+ host: string,
196
+ bindError: unknown,
197
+ blocked: string[],
198
+ ): Promise<PortInUseError> {
168
199
  const occupant = await findOccupant(port, host);
169
200
  const bindDetail = bindError instanceof Error ? `\n Bind error: ${bindError.message}` : '';
201
+ const address = addressClause(host, blocked);
170
202
 
171
203
  if (occupant) {
172
204
  return new PortInUseError(
173
- `Port ${port} is already served by Kabmo ${describeOccupant(occupant)}.\n`
205
+ `Port ${port} is already served by Kabmo ${describeOccupant(occupant)}${address}.\n`
174
206
  + ` Nothing was stopped — a starting instance never stops another one.\n`
175
207
  + ` Stop it first: kabmo stop --port ${port}\n`
176
208
  + ` Or start on another port:\n${portGuidance()}`,
@@ -178,7 +210,7 @@ async function buildPortInUseError(port: number, host: string, bindError: unknow
178
210
  }
179
211
 
180
212
  return new PortInUseError(
181
- `Port ${port} is in use by another process (no Kabmo instance answered on it).${bindDetail}\n`
213
+ `Port ${port} is in use by another process${address} (no Kabmo instance answered on it).${bindDetail}\n`
182
214
  + ` Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN\n`
183
215
  + ` Start on another port:\n${portGuidance()}`,
184
216
  );
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The IDENTIFICATION half of `probe.ts`: `GET /api/health`, which is how every
8
+ * CLI command and the port guard find out who is on a port.
9
+ *
10
+ * The cases run against real HTTP stubs on loopback, because the questions are
11
+ * about a real exchange — a status code, a body's shape, a scheme, and which
12
+ * ADDRESS the request was aimed at. That last one is the reported bug: a
13
+ * single-address request for a name that stands for two sockets reported a live
14
+ * instance as absent, so `status` and `stop` saw nothing while a browser kept
15
+ * landing on it.
16
+ */
17
+
18
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+
20
+ import { fetchHealth } from '@/server/lib/lifecycle/probe';
21
+ import { createLoopbackHarness, ipv6Loopback, realFetch } from '@/test-support/loopback';
22
+
23
+ const harness = createLoopbackHarness();
24
+ const { occupy, occupyAt, freePort, freePortBothFamilies } = harness;
25
+
26
+ /**
27
+ * Another file in the same `bun test` process may have left a stub installed on
28
+ * the global, and every case here talks to a REAL listener it just started on
29
+ * loopback — so the global is put back before each one rather than trusting the
30
+ * ambient one.
31
+ */
32
+ beforeEach(() => {
33
+ globalThis.fetch = realFetch;
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await harness.dispose();
38
+ });
39
+
40
+ afterAll(async () => {
41
+ await harness.dispose();
42
+ });
43
+
44
+ describe('fetchHealth', () => {
45
+ test('returns the payload of a Kabmo listener, keeping only known fields', async () => {
46
+ const full = occupy(() => Response.json({
47
+ service: 'kabmo',
48
+ pid: 4242,
49
+ version: '3.10.1',
50
+ startedAt: '2026-01-01T00:00:00.000Z',
51
+ uptime: 12.5,
52
+ mock: false,
53
+ runtime: 'bun',
54
+ mode: 'dev',
55
+ port: 1234,
56
+ host: '127.0.0.1',
57
+ tls: false,
58
+ tray: true,
59
+ authEnabled: true,
60
+ fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
61
+ extra: 'dropped',
62
+ }));
63
+
64
+ const health = await fetchHealth(full.port, '127.0.0.1', 1000, false);
65
+
66
+ expect(health).toEqual({
67
+ service: 'kabmo',
68
+ pid: 4242,
69
+ version: '3.10.1',
70
+ startedAt: '2026-01-01T00:00:00.000Z',
71
+ uptime: 12.5,
72
+ mock: false,
73
+ runtime: 'bun',
74
+ mode: 'dev',
75
+ port: 1234,
76
+ host: '127.0.0.1',
77
+ tls: false,
78
+ tray: true,
79
+ authEnabled: true,
80
+ // Carried, not dropped: `status` prints this as its warning line, and
81
+ // whitelisting it away left the report without one.
82
+ fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
83
+ });
84
+ });
85
+
86
+ test('reports a malformed fds field as null rather than a half-trusted object', async () => {
87
+ const partial = occupy(() => Response.json({
88
+ service: 'kabmo',
89
+ fds: { open: 1, highest: 1, limit: 2 },
90
+ }));
91
+
92
+ const health = await fetchHealth(partial.port, '127.0.0.1', 1000, false);
93
+
94
+ expect(health?.service).toBe('kabmo');
95
+ expect(health?.fds).toBeNull();
96
+ });
97
+
98
+ test('drops fields whose type is wrong rather than passing them on', async () => {
99
+ const mistyped = occupy(() => Response.json({ service: 'kabmo', pid: '4242', version: 7, tls: 'yes' }));
100
+
101
+ const health = await fetchHealth(mistyped.port, '127.0.0.1', 1000, false);
102
+
103
+ expect(health?.service).toBe('kabmo');
104
+ expect(health?.pid).toBeUndefined();
105
+ expect(health?.version).toBeUndefined();
106
+ expect(health?.tls).toBeUndefined();
107
+ });
108
+
109
+ test.skipIf(!ipv6Loopback)('finds a listener on either address of a name', async () => {
110
+ // `localhost` is two sockets and a server binds one. Asking only the family
111
+ // Bun happens to pick left the other instance undiscoverable: `kabmo status`
112
+ // and `kabmo stop` reported nothing while the browser kept reaching it.
113
+ const port = await freePortBothFamilies();
114
+ const health = () => Response.json({ service: 'kabmo', pid: process.pid });
115
+
116
+ const v4 = occupyAt(port, '127.0.0.1', health);
117
+ expect((await fetchHealth(port, 'localhost', 1000, false))?.service).toBe('kabmo');
118
+ v4.stop(true);
119
+
120
+ const v6 = occupyAt(port, '::1', health);
121
+ expect((await fetchHealth(port, 'localhost', 1000, false))?.service).toBe('kabmo');
122
+ v6.stop(true);
123
+ });
124
+
125
+ test('recognizes a pre-rename listener, and reports it under the current name', async () => {
126
+ // A server started before the rename is still running the old build: its
127
+ // health payload says `ompchamber`, and `status`/`stop` must still find it.
128
+ const legacy = occupy(() => Response.json({ service: 'ompchamber', pid: 4242, port: 3000 }));
129
+
130
+ const health = await fetchHealth(legacy.port, '127.0.0.1', 1000, false);
131
+
132
+ expect(health?.service).toBe('kabmo');
133
+ expect(health?.pid).toBe(4242);
134
+ });
135
+
136
+ test('ignores a listener that is not Kabmo', async () => {
137
+ const foreign = occupy(() => Response.json({ service: 'something-else', pid: process.pid }));
138
+ expect(await fetchHealth(foreign.port, '127.0.0.1', 1000, false)).toBeNull();
139
+ });
140
+
141
+ test('ignores error responses, non-JSON bodies, and non-objects', async () => {
142
+ const broken = occupy(() => new Response('nope', { status: 500 }));
143
+ expect(await fetchHealth(broken.port, '127.0.0.1', 1000, false)).toBeNull();
144
+
145
+ const html = occupy(() => new Response('<html>not json</html>', { headers: { 'content-type': 'text/html' } }));
146
+ expect(await fetchHealth(html.port, '127.0.0.1', 1000, false)).toBeNull();
147
+
148
+ const list = occupy(() => Response.json([{ service: 'kabmo' }]));
149
+ expect(await fetchHealth(list.port, '127.0.0.1', 1000, false)).toBeNull();
150
+ });
151
+
152
+ test('returns null for a port nobody listens on, and for a bad port', async () => {
153
+ expect(await fetchHealth(await freePort(), '127.0.0.1', 500)).toBeNull();
154
+ expect(await fetchHealth(0, '127.0.0.1', 500)).toBeNull();
155
+ expect(await fetchHealth(Number.NaN, '127.0.0.1', 500)).toBeNull();
156
+ });
157
+
158
+ test('a plaintext listener is not found over TLS, and the mode decides the scheme', async () => {
159
+ const plain = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
160
+
161
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000, true)).toBeNull();
162
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000, false)).not.toBeNull();
163
+ // 'auto' cannot know which scheme a live instance chose, so it tries both.
164
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000)).not.toBeNull();
165
+ });
166
+ });