@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
@@ -4,64 +4,34 @@
4
4
  */
5
5
 
6
6
  /**
7
- * `probe.ts` answers two questions a starting server depends on: "can I bind
8
- * this address" and "is a Kabmo server answering here". Getting either
9
- * wrong is expensive — a false "free" turns into an EADDRINUSE after the
10
- * server has already announced itself, and a false "occupied" blocks startup
11
- * on a port nobody holds — so the tests drive real listeners on ephemeral
12
- * ports (never a fixed one) and real HTTP stubs, and pin the address
13
- * translation rules that decide which address is asked about in the first
14
- * place.
7
+ * The ADDRESS RULES `probe.ts` applies before it asks anything: which address a
8
+ * bind host stands for, and whether that address can be bound.
9
+ *
10
+ * Getting either wrong is expensive — a false "free" turns into an EADDRINUSE
11
+ * after the server has already announced itself, and a false "occupied" blocks
12
+ * startup on a port nobody holds — so the cases drive real listeners on
13
+ * OS-assigned ports and pin the translation that decides which address is asked
14
+ * about in the first place. The health half of the module is `probe-health`.
15
15
  */
16
16
 
17
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
- import type { Server } from 'bun';
17
+ import { afterAll, describe, expect, test } from 'bun:test';
19
18
 
20
- import { fetchHealth, isLoopbackHost, isPortAvailable, probeHost, waitForPortFree } from '@/server/lib/lifecycle/probe';
19
+ import {
20
+ connectAddresses,
21
+ isLoopbackHost,
22
+ isPortAvailable,
23
+ probeAddresses,
24
+ probeHost,
25
+ unavailableAddresses,
26
+ waitForPortFree,
27
+ } from '@/server/lib/lifecycle/probe';
28
+ import { createLoopbackHarness, ipv6Loopback } from '@/test-support/loopback';
21
29
 
22
- const servers: Server<undefined>[] = [];
30
+ const harness = createLoopbackHarness();
31
+ const { occupy, occupyAt, freePort, freePortBothFamilies } = harness;
23
32
 
24
- /** A live listener on an OS-assigned port; the port number is the test's. The
25
- * pair shape keeps `server.port` (`number | undefined` in Bun's types) out
26
- * of every call site. */
27
- function occupy(fetch: (request: Request) => Response | Promise<Response>, hostname = '127.0.0.1'): { server: Server<undefined>; port: number } {
28
- const server = Bun.serve({ port: 0, hostname, fetch }) as Server<undefined>;
29
- const port = server.port;
30
- if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
31
- servers.push(server);
32
- return { server, port };
33
- }
34
-
35
- /**
36
- * A port that nothing holds: take one from the OS, give it back, and wait for
37
- * the release to actually land — Bun closes a stopped listener's socket on a
38
- * later event-loop turn, so an immediate check would be a race, not a test.
39
- */
40
- async function freePort(): Promise<number> {
41
- const held = occupy(() => new Response('unused'));
42
- held.server.stop(true);
43
- servers.splice(servers.indexOf(held.server), 1);
44
- if (!(await waitForPortFree(held.port, '127.0.0.1', 2_000, 25))) {
45
- throw new Error(`ephemeral port ${held.port} never became free`);
46
- }
47
- return held.port;
48
- }
49
-
50
- /**
51
- * The runner's own fetch, captured before any test runs. Another file in the
52
- * same `bun test` process may have left a stub installed on the global, and
53
- * every case here talks to a REAL listener it just started on loopback — so
54
- * the global is put back before each one rather than trusting the ambient one.
55
- */
56
- /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
57
- const realFetch = Bun.fetch;
58
-
59
- beforeEach(() => {
60
- globalThis.fetch = realFetch;
61
- });
62
-
63
- afterEach(() => {
64
- for (const server of servers.splice(0)) server.stop(true);
33
+ afterAll(async () => {
34
+ await harness.dispose();
65
35
  });
66
36
 
67
37
  describe('probeHost', () => {
@@ -82,6 +52,31 @@ describe('probeHost', () => {
82
52
  });
83
53
  });
84
54
 
55
+ describe('probeAddresses', () => {
56
+ test('a loopback name is every loopback family, because a bind picks one', () => {
57
+ // Measured: `Bun.serve({ hostname: 'localhost' })` listens on `[::1]` alone.
58
+ // Asking only about that family is how a stale instance kept `127.0.0.1`
59
+ // while the newcomer's own log reported the port free.
60
+ expect(probeAddresses('localhost')).toEqual(['127.0.0.1', '::1']);
61
+ expect(probeAddresses('LOCALHOST')).toEqual(['127.0.0.1', '::1']);
62
+ expect(probeAddresses(undefined)).toEqual(['127.0.0.1', '::1']);
63
+ expect(probeAddresses(' ')).toEqual(['127.0.0.1', '::1']);
64
+ });
65
+
66
+ test('a literal address is one address', () => {
67
+ expect(probeAddresses('127.0.0.1')).toEqual(['127.0.0.1']);
68
+ expect(probeAddresses('0.0.0.0')).toEqual(['0.0.0.0']);
69
+ expect(probeAddresses('192.168.1.4')).toEqual(['192.168.1.4']);
70
+ });
71
+
72
+ test('connect addresses translate a bind-any address, and keep the rest', () => {
73
+ expect(connectAddresses('0.0.0.0')).toEqual(['127.0.0.1']);
74
+ expect(connectAddresses('::')).toEqual(['127.0.0.1']);
75
+ expect(connectAddresses('localhost')).toEqual(['127.0.0.1', '::1']);
76
+ expect(connectAddresses('192.168.1.4')).toEqual(['192.168.1.4']);
77
+ });
78
+ });
79
+
85
80
  describe('isLoopbackHost', () => {
86
81
  test('treats an unset address as private', () => {
87
82
  expect(isLoopbackHost(undefined)).toBe(true);
@@ -122,7 +117,6 @@ describe('isPortAvailable', () => {
122
117
  expect(await isPortAvailable(heldPort, '127.0.0.1')).toBe(false);
123
118
 
124
119
  held.stop(true);
125
- servers.splice(servers.indexOf(held), 1);
126
120
  // An ephemeral port is released once the listener closes; the poll is what
127
121
  // callers use to wait for exactly that.
128
122
  expect(await waitForPortFree(heldPort, '127.0.0.1', 2_000, 25)).toBe(true);
@@ -140,12 +134,10 @@ describe('isPortAvailable', () => {
140
134
  // Linux refuses it (reads busy). Neither is pinned — both are honest
141
135
  // answers about the address asked for.
142
136
  loopback.server.stop(true);
143
- servers.splice(servers.indexOf(loopback.server), 1);
144
137
 
145
138
  const wildcard = occupy(() => new Response('held'), '0.0.0.0');
146
139
  expect(await isPortAvailable(wildcard.port, '0.0.0.0')).toBe(false);
147
140
  wildcard.server.stop(true);
148
- servers.splice(servers.indexOf(wildcard.server), 1);
149
141
 
150
142
  // The listener that held the port is gone, so the same question flips.
151
143
  expect(await waitForPortFree(loopback.port, '127.0.0.1', 2_000, 25)).toBe(true);
@@ -157,6 +149,35 @@ describe('isPortAvailable', () => {
157
149
  expect(await isPortAvailable(-1, '127.0.0.1')).toBe(false);
158
150
  expect(await isPortAvailable(Number.NaN, '127.0.0.1')).toBe(false);
159
151
  });
152
+
153
+ test.skipIf(!ipv6Loopback)('a name is busy when only ONE of its addresses is held', async () => {
154
+ // The reported failure, reduced: a stale instance holds `127.0.0.1:3000`,
155
+ // `bun run dev` binds `localhost` (i.e. `::1`) and finds that family free.
156
+ // Both servers then serve "port 3000" and the browser lands on whichever
157
+ // family it resolves — the stale one, in a different data mode.
158
+ const shared = await freePortBothFamilies();
159
+ const stale = occupyAt(shared, '127.0.0.1');
160
+ expect(await isPortAvailable(shared, 'localhost')).toBe(false);
161
+ expect(await unavailableAddresses(shared, 'localhost')).toEqual(['127.0.0.1']);
162
+ stale.stop(true);
163
+ expect(await waitForPortFree(shared, 'localhost', 2_000, 25)).toBe(true);
164
+
165
+ // The other family alone is just as busy.
166
+ const other = await freePortBothFamilies();
167
+ const v6 = occupyAt(other, '::1');
168
+ expect(await isPortAvailable(other, 'localhost')).toBe(false);
169
+ expect(await unavailableAddresses(other, 'localhost')).toEqual(['::1']);
170
+ v6.stop(true);
171
+ });
172
+
173
+ test('reports the address that is held, and none when the port is free', async () => {
174
+ const free = await freePort();
175
+ expect(await unavailableAddresses(free, '127.0.0.1')).toEqual([]);
176
+ expect(await unavailableAddresses(free, 'localhost')).toEqual([]);
177
+
178
+ const held = occupy(() => new Response('held'));
179
+ expect(await unavailableAddresses(held.port, '127.0.0.1')).toEqual(['127.0.0.1']);
180
+ });
160
181
  });
161
182
 
162
183
  describe('waitForPortFree', () => {
@@ -169,111 +190,3 @@ describe('waitForPortFree', () => {
169
190
  expect(await waitForPortFree(held.port, '127.0.0.1', 0)).toBe(false);
170
191
  });
171
192
  });
172
-
173
- describe('fetchHealth', () => {
174
- test('returns the payload of a Kabmo listener, keeping only known fields', async () => {
175
- const full = occupy(() => Response.json({
176
- service: 'kabmo',
177
- pid: 4242,
178
- version: '3.10.1',
179
- startedAt: '2026-01-01T00:00:00.000Z',
180
- uptime: 12.5,
181
- mock: false,
182
- runtime: 'bun',
183
- mode: 'dev',
184
- port: 1234,
185
- host: '127.0.0.1',
186
- tls: false,
187
- tray: true,
188
- authEnabled: true,
189
- fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
190
- extra: 'dropped',
191
- }));
192
-
193
- const health = await fetchHealth(full.port, '127.0.0.1', 1000, false);
194
-
195
- expect(health).toEqual({
196
- service: 'kabmo',
197
- pid: 4242,
198
- version: '3.10.1',
199
- startedAt: '2026-01-01T00:00:00.000Z',
200
- uptime: 12.5,
201
- mock: false,
202
- runtime: 'bun',
203
- mode: 'dev',
204
- port: 1234,
205
- host: '127.0.0.1',
206
- tls: false,
207
- tray: true,
208
- authEnabled: true,
209
- // Carried, not dropped: `status` prints this as its warning line, and
210
- // whitelisting it away left the report without one.
211
- fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
212
- });
213
- });
214
-
215
- test('reports a malformed fds field as null rather than a half-trusted object', async () => {
216
- const partial = occupy(() => Response.json({
217
- service: 'kabmo',
218
- fds: { open: 1, highest: 1, limit: 2 },
219
- }));
220
-
221
- const health = await fetchHealth(partial.port, '127.0.0.1', 1000, false);
222
-
223
- expect(health?.service).toBe('kabmo');
224
- expect(health?.fds).toBeNull();
225
- });
226
-
227
- test('drops fields whose type is wrong rather than passing them on', async () => {
228
- const mistyped = occupy(() => Response.json({ service: 'kabmo', pid: '4242', version: 7, tls: 'yes' }));
229
-
230
- const health = await fetchHealth(mistyped.port, '127.0.0.1', 1000, false);
231
-
232
- expect(health?.service).toBe('kabmo');
233
- expect(health?.pid).toBeUndefined();
234
- expect(health?.version).toBeUndefined();
235
- expect(health?.tls).toBeUndefined();
236
- });
237
-
238
- test('recognizes a pre-rename listener, and reports it under the current name', async () => {
239
- // A server started before the rename is still running the old build: its
240
- // health payload says `ompchamber`, and `status`/`stop` must still find it.
241
- const legacy = occupy(() => Response.json({ service: 'ompchamber', pid: 4242, port: 3000 }));
242
-
243
- const health = await fetchHealth(legacy.port, '127.0.0.1', 1000, false);
244
-
245
- expect(health?.service).toBe('kabmo');
246
- expect(health?.pid).toBe(4242);
247
- });
248
-
249
- test('ignores a listener that is not Kabmo', async () => {
250
- const foreign = occupy(() => Response.json({ service: 'something-else', pid: process.pid }));
251
- expect(await fetchHealth(foreign.port, '127.0.0.1', 1000, false)).toBeNull();
252
- });
253
-
254
- test('ignores error responses, non-JSON bodies, and non-objects', async () => {
255
- const broken = occupy(() => new Response('nope', { status: 500 }));
256
- expect(await fetchHealth(broken.port, '127.0.0.1', 1000, false)).toBeNull();
257
-
258
- const html = occupy(() => new Response('<html>not json</html>', { headers: { 'content-type': 'text/html' } }));
259
- expect(await fetchHealth(html.port, '127.0.0.1', 1000, false)).toBeNull();
260
-
261
- const list = occupy(() => Response.json([{ service: 'kabmo' }]));
262
- expect(await fetchHealth(list.port, '127.0.0.1', 1000, false)).toBeNull();
263
- });
264
-
265
- test('returns null for a port nobody listens on, and for a bad port', async () => {
266
- expect(await fetchHealth(await freePort(), '127.0.0.1', 500)).toBeNull();
267
- expect(await fetchHealth(0, '127.0.0.1', 500)).toBeNull();
268
- expect(await fetchHealth(Number.NaN, '127.0.0.1', 500)).toBeNull();
269
- });
270
-
271
- test('a plaintext listener is not found over TLS, and the mode decides the scheme', async () => {
272
- const plain = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
273
-
274
- expect(await fetchHealth(plain.port, '127.0.0.1', 1000, true)).toBeNull();
275
- expect(await fetchHealth(plain.port, '127.0.0.1', 1000, false)).not.toBeNull();
276
- // 'auto' cannot know which scheme a live instance chose, so it tries both.
277
- expect(await fetchHealth(plain.port, '127.0.0.1', 1000)).not.toBeNull();
278
- });
279
- });
@@ -69,27 +69,100 @@ export function isLoopbackHost(host?: string | null): boolean {
69
69
  }
70
70
 
71
71
  /**
72
- * True when `port` can still be bound on `host`.
72
+ * Addresses a bind to `host` can land on.
73
+ *
74
+ * A NAME is not one address, and that is the whole bug this function exists
75
+ * for. `localhost` resolves to both loopback families, while Bun binds only the
76
+ * one it resolves first — verified: `Bun.serve({ hostname: 'localhost' })`
77
+ * listens on `[::1]` alone. Asking about that family alone answers "is the
78
+ * socket I am about to open free", which is not the question a port guard
79
+ * exists to answer: the other address stays available to a second server, and a
80
+ * client that resolves THAT one reaches it. Measured on a real dev run against a
81
+ * stale instance on `127.0.0.1:3000`: both servers reported the port as theirs,
82
+ * the browser landed on the old build (a MOCK instance), and the newcomer's own
83
+ * log said the port was free.
73
84
  *
74
- * The probe binds *the same address the server will bind* — a wildcard server
75
- * is probed with a wildcard bind. Probing loopback instead would ask a
76
- * different question: `net` sets SO_REUSEADDR, so a loopback bind can succeed
77
- * while `0.0.0.0:<port>` is already taken, and the port would look free right
78
- * up until the real `listen` failed.
85
+ * A literal address and a bind-any address are one address by definition. An
86
+ * arbitrary hostname is deliberately left alone: its resolution belongs to the
87
+ * OS, a second lookup here could disagree with the one the bind will use, and
88
+ * only loopback is guaranteed multi-family on every host.
79
89
  */
80
- export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
81
- if (!Number.isFinite(port) || port <= 0) return false;
82
- const bindHost = (host ?? '').trim() || 'localhost';
90
+ export function probeAddresses(host?: string | null): string[] {
91
+ const value = (host ?? '').trim();
92
+ if (value.length === 0 || value.toLowerCase() === 'localhost') return ['127.0.0.1', '::1'];
93
+ return [value];
94
+ }
95
+
96
+ /**
97
+ * Addresses a health request should be sent to, for a bind host.
98
+ *
99
+ * The same list as `probeAddresses`, with bind-any addresses translated to the
100
+ * loopback that reaches them. Kept separate because the two questions differ:
101
+ * occupancy is asked with a BIND (so `0.0.0.0` is a legitimate address to bind)
102
+ * while identification is asked with a CONNECT (where `0.0.0.0` is not an
103
+ * address at all).
104
+ */
105
+ export function connectAddresses(host?: string | null): string[] {
106
+ return probeAddresses(host).map(probeHost);
107
+ }
108
+
109
+ /**
110
+ * Bind failures that mean "this host has no such address", not "someone holds
111
+ * it": a kernel booted without IPv6 answers `EADDRNOTAVAIL` for `::1`, and
112
+ * reading that as occupied would refuse every start on that machine.
113
+ */
114
+ const ABSENT_ADDRESS_CODES: Record<string, true> = {
115
+ EADDRNOTAVAIL: true,
116
+ EAFNOSUPPORT: true,
117
+ };
118
+
119
+ /** Whether one address can be bound. */
120
+ async function bindable(port: number, host: string): Promise<boolean> {
83
121
  const { promise, resolve } = Promise.withResolvers<boolean>();
84
122
  const probe = net.createServer();
85
123
  probe.unref();
86
- probe.once('error', () => resolve(false));
87
- probe.listen({ port, host: bindHost }, () => {
124
+ probe.once('error', (error: NodeJS.ErrnoException) => {
125
+ resolve(ABSENT_ADDRESS_CODES[error.code ?? ''] === true);
126
+ });
127
+ probe.listen({ port, host }, () => {
88
128
  probe.close(() => resolve(true));
89
129
  });
90
130
  return await promise;
91
131
  }
92
132
 
133
+ /**
134
+ * Every address of `host` where `port` is already taken; empty means free.
135
+ *
136
+ * The list rather than a boolean, because the guard's failure message has to
137
+ * name the address that is held — "port busy" on a name that resolves to two
138
+ * addresses does not say which one the user has to free.
139
+ */
140
+ export async function unavailableAddresses(port: number, host?: string | null): Promise<string[]> {
141
+ if (!Number.isFinite(port) || port <= 0) return [];
142
+ const blocked: string[] = [];
143
+ for (const address of probeAddresses(host)) {
144
+ if (!(await bindable(port, address))) blocked.push(address);
145
+ }
146
+ return blocked;
147
+ }
148
+
149
+ /**
150
+ * True when `port` can still be bound on `host`.
151
+ *
152
+ * The probe binds *the same addresses the server will bind* — a wildcard server
153
+ * is probed with a wildcard bind, and a name is probed on every address it
154
+ * resolves to. Probing loopback instead of a wildcard would ask a different
155
+ * question: `net` sets SO_REUSEADDR, so a loopback bind can succeed while
156
+ * `0.0.0.0:<port>` is already taken, and the port would look free right up
157
+ * until the real `listen` failed. Probing one family of a name asks a narrower
158
+ * question still: whether *this* socket is free, when the answer that matters
159
+ * is whether the port is free for the name a browser will type.
160
+ */
161
+ export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
162
+ if (!Number.isFinite(port) || port <= 0) return false;
163
+ return (await unavailableAddresses(port, host)).length === 0;
164
+ }
165
+
93
166
  /** Poll until the port can be bound, or the deadline passes. */
94
167
  export async function waitForPortFree(
95
168
  port: number,
@@ -136,6 +209,20 @@ export interface HealthPayload {
136
209
  fds?: FdPressure | null;
137
210
  }
138
211
 
212
+ /**
213
+ * A host as a URL authority.
214
+ *
215
+ * An IPv6 literal has to be bracketed: `http://::1:3000/api/health` is not a
216
+ * URL, and the failure is a throw rather than a connection attempt — so asking
217
+ * `::1` without this reads as "nothing there", which is the same silent answer
218
+ * the multi-address probe exists to stop. `localhost` never reaches this branch
219
+ * (no colon), and an already-bracketed address is left alone.
220
+ */
221
+ function urlAuthority(host: string): string {
222
+ if (host.startsWith('[') || !host.includes(':')) return host;
223
+ return `[${host}]`;
224
+ }
225
+
139
226
  /**
140
227
  * One health request over `scheme`.
141
228
  *
@@ -152,7 +239,7 @@ async function probeHealthOver(
152
239
  timeoutMs: number,
153
240
  ): Promise<HealthPayload | null> {
154
241
  try {
155
- const response = await fetch(`${scheme}://${host}:${port}/api/health`, {
242
+ const response = await fetch(`${scheme}://${urlAuthority(host)}:${port}/api/health`, {
156
243
  headers: { accept: 'application/json' },
157
244
  signal: AbortSignal.timeout(timeoutMs),
158
245
  ...(scheme === 'https' ? { tls: { rejectUnauthorized: false } } : {}),
@@ -194,6 +281,15 @@ async function probeHealthOver(
194
281
  * caller here is asking "is a Kabmo server on this port", so a foreign
195
282
  * HTTP service answers null rather than a half-trusted object.
196
283
  *
284
+ * A NAME is asked on every address it resolves to, concurrently, and the first
285
+ * answer wins. A single-address request is the false negative that made a live
286
+ * instance invisible to `status` and `stop`: `localhost` resolves to both
287
+ * loopback families, a server binds only one of them, and a request aimed at the
288
+ * other reaches nothing — so a MOCK instance holding `127.0.0.1:3000` answered
289
+ * no CLI command while the browser kept landing on it. Concurrent rather than
290
+ * sequential because a port nobody serves must not cost one timeout per family,
291
+ * and ECONNREFUSED on the family with no listener returns immediately.
292
+ *
197
293
  * `tls: 'auto'` (the default) tries HTTPS first, then HTTP. Discovery cannot
198
294
  * know which a live instance chose, and the two are mutually exclusive on a port,
199
295
  * so one wasted request is the cheapest way to be right. A caller that DOES know
@@ -206,7 +302,19 @@ export async function fetchHealth(
206
302
  tls: boolean | 'auto' = 'auto',
207
303
  ): Promise<HealthPayload | null> {
208
304
  if (!Number.isFinite(port) || port <= 0) return null;
209
- const target = probeHost(host);
305
+ const answers = await Promise.all(
306
+ connectAddresses(host).map((target) => probeHealthOn(port, target, timeoutMs, tls)),
307
+ );
308
+ return answers.find((answer) => answer !== null) ?? null;
309
+ }
310
+
311
+ /** One address, in whichever scheme the caller allows. */
312
+ async function probeHealthOn(
313
+ port: number,
314
+ target: string,
315
+ timeoutMs: number,
316
+ tls: boolean | 'auto',
317
+ ): Promise<HealthPayload | null> {
210
318
  if (tls === true) return probeHealthOver('https', port, target, timeoutMs);
211
319
  if (tls === false) return probeHealthOver('http', port, target, timeoutMs);
212
320
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * What a completed tool call republishes, and — the load-bearing half — what it
8
+ * does NOT.
9
+ *
10
+ * The shipped shape republished the whole `sidebar` structure here: every
11
+ * folder and session, re-scanned from disk. Measured on the author's install,
12
+ * that is 216 KB over 731 sessions PER TOOL CALL, against 87 bytes for the
13
+ * activity entry it actually needed to carry, and it is what made a phone's
14
+ * stream feel slow. These cases pin the split:
15
+ *
16
+ * - the activity signal fires per tool call;
17
+ * - the STRUCTURE signal does not, because nothing about the LIST changed (a
18
+ * new session's file is the watcher's `rename` event, a rename is
19
+ * `session_info_update`, and the turn boundary is `settleTerminalRun`);
20
+ * - the data signal still fires, because a tool result IS a transcript entry.
21
+ *
22
+ * Split from `frame-fold.test.ts` when the two together crossed the repo's
23
+ * per-file ceiling.
24
+ */
25
+
26
+ import { describe, expect, test } from 'bun:test';
27
+
28
+ import { foldSessionFrame, type SessionFrameHost } from '@/server/lib/omp/rpc/frame-fold';
29
+ import { onRealtimeSignal } from '@/server/lib/realtime/signals.server';
30
+ import { ModeMirror } from '@/server/lib/omp/rpc/mode-mirror';
31
+
32
+ /** The same minimal host the sibling suite uses. */
33
+ function makeHost(overrides: Partial<SessionFrameHost> = {}): SessionFrameHost {
34
+ return {
35
+ sessionId: '',
36
+ autoTitleWindowUntil: 0,
37
+ autoTitleClaims: 0,
38
+ autoTitleDispatch: null,
39
+ autoTitlePending: true,
40
+ autoTitleRequested: false,
41
+ promptRunning: false,
42
+ streaming: false,
43
+ compacting: false,
44
+ awaitingAgentStart: false,
45
+ awaitingAgentStartDeadline: 0,
46
+ continuationGraceUntil: 0,
47
+ proc: { sendCommand: async () => undefined as never },
48
+ emit() {},
49
+ trackUiDialog() {},
50
+ observeSubagent() {},
51
+ syncRunModel() {},
52
+ modeMirror: new ModeMirror(),
53
+ isAlive: () => true,
54
+ isBusy: () => false,
55
+ send: async () => undefined,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The `tool_execution_end` frame is what moves a running session's sidebar row
62
+ * between turn boundaries — and it must move ONLY the two live fields.
63
+ *
64
+ * The shipped shape republished the whole `sidebar` structure here: every
65
+ * folder and session, re-scanned from disk. Measured on the author's install,
66
+ * that is 216 KB over 731 sessions PER TOOL CALL, against 87 bytes for the
67
+ * activity entry it actually needed to carry — and it is what made a phone's
68
+ * stream feel slow. The rules pinned below are the ones that make the smaller
69
+ * payload sufficient:
70
+ *
71
+ * - the activity signal fires per tool call;
72
+ * - the STRUCTURE signal does not, because nothing about the list changed (a
73
+ * new session's file is the watcher's `rename` event, a rename is
74
+ * `session_info_update`, and the turn boundary is `settleTerminalRun`);
75
+ * - the data signal still fires, because a tool result IS a transcript entry.
76
+ */
77
+ describe('tool_execution_end publishes activity, never the structure', () => {
78
+ test('raises the activity signal and leaves the structure alone', () => {
79
+ const seen: string[] = [];
80
+ const off = onRealtimeSignal((event) => seen.push(event.signal));
81
+ try {
82
+ const host = makeHost({ sessionId: 's1' });
83
+ foldSessionFrame(host, { type: 'tool_execution_end' });
84
+ } finally {
85
+ off();
86
+ }
87
+
88
+ expect(seen).toContain('sidebar-activity');
89
+ expect(seen).not.toContain('sidebar-structure');
90
+ // The transcript grew, so the session's data topics re-read.
91
+ expect(seen).toContain('session-data-dirty');
92
+ expect(seen).toContain('workspace-dirty');
93
+ });
94
+
95
+ test('still publishes the structure at the turn boundary', () => {
96
+ // The other half of the split: the final answer appends after the last tool
97
+ // call, and the run's own settle is where the list catches up. Asserted
98
+ // through the scan cache, which is the observable a structure publish
99
+ // leaves behind (`settleTerminalRun` calls the publisher directly rather
100
+ // than raising the signal).
101
+ globalThis.__kabmoSidebarDataCache = {
102
+ data: {} as never,
103
+ expiresAt: Date.now() + 60_000,
104
+ inFlight: null,
105
+ };
106
+
107
+ const host = makeHost({ sessionId: 's1' });
108
+ foldSessionFrame(host, { type: 'agent_end', isTerminal: true, messages: [] });
109
+
110
+ expect(globalThis.__kabmoSidebarDataCache?.expiresAt).toBe(0);
111
+ });
112
+ });
@@ -117,14 +117,19 @@ function handleMessageEnd(host: SessionFrameHost, event: AgentEvent): void {
117
117
  * pays nothing. The session's data topics (todos, telemetry, queue) stay
118
118
  * turn-boundary-only on purpose: their resolvers are the expensive ones
119
119
  * (whole-transcript parses/scans).
120
+ *
121
+ * The SIDEBAR's activity stamp moved too, and it is published on its own topic.
122
+ * It used to republish the whole structure here — every folder and session —
123
+ * and that is what made a phone's stream feel slow: measured 216 KB over 731
124
+ * sessions per tool call, against 87 bytes for the activity payload. Nothing
125
+ * about the LIST changes on a transcript append, so nothing about the list is
126
+ * republished: a new session's file is the watcher's `rename` event, a rename is
127
+ * `session_info_update`, and the turn boundary is `settleTerminalRun` — each
128
+ * already publishes the structure for its own reason.
120
129
  */
121
130
  function handleToolExecutionEnd(host: SessionFrameHost): void {
122
131
  emitRealtimeSignal('workspace-dirty');
123
- // The transcript appended too — the sidebar's `updated_at` (its ordering and
124
- // time-ago labels) is read off a scan that only re-runs on this signal, and
125
- // the sessions watcher deliberately ignores transcript appends. The coalesce +
126
- // dedupe above bound the cost the same way.
127
- publishSidebarStructure();
132
+ emitRealtimeSignal('sidebar-activity');
128
133
  // The session's DATA moved as well: a tool result is a transcript entry, so
129
134
  // the raw-messages paged view and the session-data topics re-read. Their
130
135
  // resolvers are the expensive ones (whole-transcript parses), but the