@kabmo-labs/kabmo 0.8.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -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/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -52,24 +52,37 @@ export function useFileTabs(
52
52
  [sessionId],
53
53
  );
54
54
 
55
- const activeRootRef = useRef<string | null | undefined>(undefined);
55
+ /** The last root a wipe was performed FOR; `null` until one is known. */
56
+ const wipedRootRef = useRef<string | null>(null);
56
57
  const { ready: layoutReady } = useSessionStateContext();
57
- const wipedRootRef = useRef<string | null | undefined>(undefined);
58
58
 
59
59
  useEffect(() => {
60
- if (activeRootRef.current === activeProjectPath) return;
61
- activeRootRef.current = activeProjectPath;
62
- if (!layoutReady || wipedRootRef.current === activeProjectPath) return;
60
+ // Only a CHANGE from one known root to another closes the previous
61
+ // workspace's tabs.
62
+ //
63
+ // Two rules, and each was a bug first. A root that is not known yet is not a
64
+ // root that changed: the path is derived from the sidebar's folder list,
65
+ // which is empty for a beat on every load (and again whenever the structure
66
+ // topic re-subscribes), so treating `null` as a root wiped the tabs a user
67
+ // had just opened. And the FIRST known root is not a change either — it is
68
+ // the workspace the page was already showing, so wiping on it discarded the
69
+ // tabs the session blob had just restored (measured: the e2e specs had to
70
+ // gate every tree click on the `/state` response to survive it, and a click
71
+ // that lost that race left the editor reading "Select a file to open" over
72
+ // the file the user had opened).
73
+ if (!activeProjectPath || !layoutReady) return;
74
+ const previous = wipedRootRef.current;
63
75
  wipedRootRef.current = activeProjectPath;
76
+ if (previous === null || previous === activeProjectPath) return;
64
77
  // The tabs are dropped, but their scroll offsets deliberately are NOT: this
65
- // wipe is not a user closing a tab. It fires on every load (the root is null
66
- // until the folder resolves), so dropping here erased the offset the session
67
- // blob had just restored — measured: the offset persisted as `900`, and came
68
- // back as `{}` after a reload. The entry is keyed by the file's path hash, so
69
- // it is reused when that file is opened again, which is the point.
78
+ // wipe is not a user closing a tab. Dropping here erased the offset the
79
+ // session blob had just restored — measured: the offset persisted as `900`,
80
+ // and came back as `{}` after a reload. The entry is keyed by the file's
81
+ // path hash, so it is reused when that file is opened again, which is the
82
+ // point.
70
83
  setOpenedFiles([]);
71
84
  setActiveFileId(null);
72
- }, [activeProjectPath, sessionId, layoutReady, setOpenedFiles, setActiveFileId]);
85
+ }, [activeProjectPath, layoutReady, setOpenedFiles, setActiveFileId]);
73
86
 
74
87
  const handleOpenFile = useCallback((file: OpenFileInput) => {
75
88
  const fileEntry: OpenedFile = {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The navbar's session menu, resolved from the session ON SCREEN.
8
+ *
9
+ * A hook rather than three statements in the layout because the lookup has a
10
+ * rule of its own: the session's row may live in a folder that is not expanded
11
+ * in the sidebar (or not visible at all), so it is found by id across every
12
+ * folder instead of by walking the visible list. No row means no menu — a
13
+ * session the sidebar does not know (a chat running outside every registered
14
+ * workspace) gets nothing rather than dead items.
15
+ *
16
+ * The pending `new-…` refusal lives here too: such a chat has no transcript
17
+ * anywhere yet, so the server refuses rename and delete, and the menu hides
18
+ * them the way the sidebar does.
19
+ */
20
+
21
+ import { useMemo } from 'preact/hooks';
22
+ import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
23
+ import { useSessionActions } from '@/client/hooks/workspace/session-actions';
24
+ import { useSessionDelete, type SessionDeleteActions } from '@/client/hooks/workspace/session-delete';
25
+ import type { WorkspaceFolderData } from '@/shared/types';
26
+
27
+ /** What `TopNavbar` needs to draw its title menu. */
28
+ export interface NavbarSessionMenu {
29
+ isArchived: boolean;
30
+ onRename?: (name: string) => void;
31
+ onRenameWithAi?: () => void;
32
+ onArchive: () => void;
33
+ onDelete?: () => void;
34
+ }
35
+
36
+ export interface NavbarSessionActions {
37
+ menu: NavbarSessionMenu | undefined;
38
+ /** The delete confirmation's own state machine, which the layout renders. */
39
+ sessionDelete: SessionDeleteActions;
40
+ }
41
+
42
+ export function useNavbarSessionActions(
43
+ folders: WorkspaceFolderData[],
44
+ sessionId: string | null,
45
+ report: (message: string, tone?: 'success' | 'error') => void,
46
+ ): NavbarSessionActions {
47
+ const sidebar = useSidebarData();
48
+ const actions = useSessionActions(sidebar.refresh, report);
49
+ const sessionDelete = useSessionDelete();
50
+
51
+ const activeSession = useMemo(() => {
52
+ if (!sessionId) return null;
53
+ for (const folder of folders) {
54
+ const found = (folder.sessions || []).find((s) => String(s.id) === String(sessionId));
55
+ if (found) return found;
56
+ }
57
+ return null;
58
+ }, [folders, sessionId]);
59
+
60
+ const pending = Boolean(sessionId && sessionId.startsWith('new-'));
61
+ const menu: NavbarSessionMenu | undefined = activeSession
62
+ ? {
63
+ isArchived: activeSession.is_archived === 1,
64
+ onRename: pending ? undefined : (name: string) => void actions.handleRename(activeSession, name),
65
+ onRenameWithAi: pending ? undefined : () => void actions.handleRenameWithAi(activeSession),
66
+ onArchive: () => actions.handleArchive(activeSession),
67
+ onDelete: pending ? undefined : () => sessionDelete.requestDelete(activeSession),
68
+ }
69
+ : undefined;
70
+
71
+ return { menu, sessionDelete };
72
+ }
@@ -58,7 +58,7 @@ async function readFailure(response: Response): Promise<never> {
58
58
  export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
59
59
  const [payload, setPayload] = useState<WorktreeListPayload | null>(null);
60
60
  const [loading, setLoading] = useState(false);
61
- const [error, setError] = useState<string | null>(null);
61
+ const [failure, setFailure] = useState<WorktreeFailure | null>(null);
62
62
 
63
63
  const refresh = useCallback(async () => {
64
64
  if (!enabled) return;
@@ -67,11 +67,18 @@ export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
67
67
  const response = await fetch(`/api/worktrees${queryFor(scope)}`);
68
68
  if (!response.ok) await readFailure(response);
69
69
  setPayload((await response.json()) as WorktreeListPayload);
70
- setError(null);
70
+ setFailure(null);
71
71
  } catch (cause) {
72
72
  // Kept apart from the payload: an empty list and a failed read are
73
- // different answers, and only one of them means nothing is there.
74
- setError(cause instanceof Error ? cause.message : String(cause));
73
+ // different answers, and only one of them means nothing is there. The
74
+ // CODE travels too, because "this folder is not a repository" and "git
75
+ // could not answer" are different states to draw — and the first one is
76
+ // how a caller knows to offer no worktree control at all.
77
+ setFailure(
78
+ cause instanceof WorktreeRequestError
79
+ ? cause.failure
80
+ : { code: 'create_failed', message: cause instanceof Error ? cause.message : String(cause) },
81
+ );
75
82
  } finally {
76
83
  setLoading(false);
77
84
  }
@@ -84,10 +91,18 @@ export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
84
91
  return {
85
92
  payload,
86
93
  worktrees: payload?.worktrees ?? ([] as WorktreeInfo[]),
94
+ /**
95
+ * Whether the project is a git repository at all. `null` while it is still
96
+ * unknown: a caller that draws a worktree control must wait for the answer
97
+ * rather than offer one a plain folder can never satisfy.
98
+ */
99
+ isRepo: payload ? payload.isRepo : null,
87
100
  /** The resolved parent directory a create would use. */
88
101
  base: payload?.base ?? '',
89
102
  loading,
90
- error,
103
+ failure,
104
+ /** The failure's message, for the surfaces that only print one. */
105
+ error: failure?.message ?? null,
91
106
  refresh,
92
107
  };
93
108
  }
@@ -30,6 +30,14 @@
30
30
  export interface DesktopInfo {
31
31
  platform: string;
32
32
  shellVersion: string;
33
+ /**
34
+ * The machine's architecture, in the canonical vocabulary (`arm64` / `x64`).
35
+ *
36
+ * Every desktop artifact is per-architecture, so without this the console can
37
+ * only offer the release page. Absent on a shell too old to report it, which
38
+ * degrades the offer to that page rather than breaking it.
39
+ */
40
+ arch?: string;
33
41
  /** True when the shell hides the native title bar and the page draws it. */
34
42
  overlayTitlebar: boolean;
35
43
  /**
@@ -156,3 +164,40 @@ export function windowToggleMaximize(): Promise<void> {
156
164
  export function windowClose(): Promise<void> {
157
165
  return call<void>('window_close');
158
166
  }
167
+
168
+ /**
169
+ * What the shell did with an update it was handed.
170
+ *
171
+ * The shell owns the whole run — download, verify, replace, relaunch — because
172
+ * only it can do any of those things: a page cannot replace the executable it is
173
+ * running inside, and a page's "download" leaves a file in a folder the user has
174
+ * to find. This is the report it sends back.
175
+ *
176
+ * `relaunching` is the honest terminal state on success: the shell is about to
177
+ * quit, so a caller that keeps a spinner going is describing a page that is
178
+ * already gone.
179
+ */
180
+ export interface ShellUpdateOutcome {
181
+ success: boolean;
182
+ /** The version the shell installed, when it could read one. */
183
+ version?: string | null;
184
+ /** A sentence for the user — the shell's own, never a generic failure. */
185
+ message: string;
186
+ relaunching?: boolean;
187
+ }
188
+
189
+ /**
190
+ * Ask the shell to install the build it just offered.
191
+ *
192
+ * `url` and `version` are the console's answer, passed through rather than
193
+ * re-derived: the shell has no release list of its own, and asking it to fetch
194
+ * one would be a second reader of the same GitHub endpoint.
195
+ *
196
+ * The shell VERIFIES the bytes before installing them (the platform's own check —
197
+ * a code signature, an archive shape, a version floor). A console that is
198
+ * compromised can therefore make the shell download, but not make it run
199
+ * something the platform will not vouch for.
200
+ */
201
+ export function applyShellUpdate(url: string, version: string): Promise<ShellUpdateOutcome> {
202
+ return call<ShellUpdateOutcome>('update_apply', { url, version });
203
+ }
@@ -23,7 +23,7 @@
23
23
  * editor can decide by equality whether the request is for the file it holds.
24
24
  */
25
25
 
26
- import type { FindOptions } from '@/shared/lib/code/editor/find';
26
+ import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
27
27
 
28
28
  export interface SearchRevealRequest {
29
29
  /** Workspace-relative path of the file the match lives in. */
@@ -1,6 +1,7 @@
1
1
  import { hydrate } from 'preact';
2
2
  import { App } from '@/client/App';
3
3
  import { bootSyntax } from '@/shared/lib/code/highlighter';
4
+ import { warmSyntaxTokenizers } from '@/shared/lib/code/highlighter-warmup';
4
5
  import { primeKabmoSettings } from '@/shared/lib/settings/client';
5
6
  import { initDocumentTheme } from '@/client/hooks/ui/theme';
6
7
  import { primeWindowChrome } from '@/client/hooks/ui/window-chrome';
@@ -70,7 +71,10 @@ installFileLaunchHandler((result) => {
70
71
  });
71
72
 
72
73
  // Fire-and-forget: warms the Shiki highlighter in parallel with hydration.
73
- bootSyntax();
74
+ // Once the eager grammars land, each is tokenized once on a tiny sample during
75
+ // idle time — loading a grammar is not compiling it, and the compile used to be
76
+ // paid inside the first tool card the reader expanded.
77
+ void bootSyntax().then(() => warmSyntaxTokenizers());
74
78
 
75
79
  if (root) {
76
80
  hydrate(
@@ -384,6 +384,43 @@
384
384
  .shiki span { color: var(--shiki-light); }
385
385
  html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
386
386
 
387
+ /* No per-token underline in a diff.
388
+ *
389
+ * `@codemirror/merge` wraps the characters that changed in `.cm-changedText`
390
+ * (and a deleted chunk's in `.cm-deletedText`) and its BASE THEME paints a 2px
391
+ * gradient along the bottom of each fragment. A word-level change is usually
392
+ * several fragments — `2` → `22` is one — so the reader got a run of short
393
+ * coloured bars under the code that reads as a second, broken baseline rather
394
+ * than as emphasis. The row's own band and the gutter marker already say which
395
+ * side a row belongs to.
396
+ *
397
+ * Three facts about how this has to be written, each measured:
398
+ *
399
+ * 1. **It cannot be suppressed from the panel's own `EditorView.theme`.** That
400
+ * theme rejects the `&light`/`&dark` selectors the package's rules are spelled
401
+ * with (`RangeError: Unsupported selector: &light`, which took the whole
402
+ * merge view down and left an empty host).
403
+ * 2. **A plain `.cm-changedText { background: none }` LOSES.** The package's rule
404
+ * is `.ͼ2.cm-merge-b .cm-changedText` — two classes on the root plus one on the
405
+ * span, i.e. specificity (0,3,0) — and CodeMirror mounts its style modules
406
+ * after this sheet, so document order is against us as well.
407
+ * 3. **So the selector carries three class-level parts of its own** (`.cm-editor`
408
+ * + `.cm-merge-b`/`.cm-merge-a` + the span class), which wins on specificity
409
+ * rather than by `!important` — an `!important` here would also beat a
410
+ * future theme that legitimately wants a different treatment, and this is a
411
+ * cosmetic suppression, not a hard override.
412
+ *
413
+ * `diff-panel.e2e.ts` asserts the computed `background-image` on a real changed
414
+ * token, so a package bump that reintroduces the bar fails there rather than in
415
+ * a screenshot review. */
416
+ .cm-editor.cm-merge-a .cm-changedText,
417
+ .cm-editor.cm-merge-b .cm-changedText,
418
+ .cm-editor.cm-merge-a .cm-deletedChunk .cm-deletedText,
419
+ .cm-editor.cm-merge-b .cm-deletedChunk .cm-deletedText,
420
+ .cm-editor .cm-deletedChunk .cm-deletedText {
421
+ background: none;
422
+ }
423
+
387
424
  /* Find matches, painted inside the token spans the highlighter emits. An
388
425
  * inline <mark> colours a background without moving a glyph, which is what
389
426
  * keeps the transparent <textarea> above aligned with this layer — a block or
@@ -400,14 +437,6 @@ html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
400
437
  color: inherit;
401
438
  background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
402
439
  }
403
- /* An extra ⌘D selection: same geometry as a find match, different ink. The
404
- * caret can only live in the primary range, so the two must not look alike —
405
- * the user has to see where their keystrokes will land. */
406
- .shiki .find-match[data-occurrence] {
407
- background: color-mix(in srgb, var(--theme-info) 30%, transparent);
408
- border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
409
- border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
410
- }
411
440
  .shiki .find-match[data-find-current] {
412
441
  background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
413
442
  border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
@@ -30,6 +30,18 @@ describe('public paths', () => {
30
30
  expect(isPublicPath('/login')).toBe(true);
31
31
  });
32
32
 
33
+ /**
34
+ * The two shells ask their update check from native code, which has no
35
+ * session cookie — and the button is for a shell so stale it may not be able
36
+ * to load the console at all. Gating these answered `401` to every shell on a
37
+ * password-protected console, which the settings screen could only report as
38
+ * "Could not ask the server" (measured against a real install).
39
+ */
40
+ test('the native shells can ask what is published without a session', () => {
41
+ expect(isPublicPath('/api/updates/android')).toBe(true);
42
+ expect(isPublicPath('/api/updates/ios')).toBe(true);
43
+ });
44
+
33
45
  test('the assets the login screen is built from are public', () => {
34
46
  for (const path of [
35
47
  '/_dev-assets/client/index-abc.js',
@@ -153,6 +165,22 @@ describe('authentication is per run, never inherited from disk', () => {
153
165
  expect(response?.status).toBe(401);
154
166
  });
155
167
 
168
+ /**
169
+ * The bug this pins: both shells ask their update check from NATIVE code,
170
+ * which carries no cookie, so a password-protected console answered `401` and
171
+ * the settings screen could only say "Could not ask the server" (measured on a
172
+ * real install). The desktop route stays gated because the CONSOLE asks it,
173
+ * and the console is behind the login.
174
+ */
175
+ test('a password does not gate the native shells\u2019 update check', async () => {
176
+ useTempDataDir();
177
+ await initAuth('correct-horse');
178
+
179
+ expect(authGate(new Request('http://localhost/api/updates/android'))).toBeUndefined();
180
+ expect(authGate(new Request('http://localhost/api/updates/ios'))).toBeUndefined();
181
+ expect(authGate(new Request('http://localhost/api/updates/desktop?kind=macos'))?.status).toBe(401);
182
+ });
183
+
156
184
  test('a document request is redirected to the login screen, not answered 401', async () => {
157
185
  useTempDataDir();
158
186
  await initAuth('correct-horse');
@@ -41,6 +41,16 @@ const LOGIN_PATH = '/login';
41
41
  /** Paths that never require a session, because they ARE the way in. */
42
42
  const PUBLIC_EXACT = new Set([
43
43
  '/api/health',
44
+ // The native shells' own update check. Android and iOS ask these from
45
+ // NATIVE code (`AppUpdateCheck.kt`, `SettingsViewController.fetchRelease`),
46
+ // which carries no WebView cookie — and the button exists precisely for a
47
+ // shell too stale to load the console, so it cannot depend on a session
48
+ // either. Both answer GitHub's public release list (a version and a download
49
+ // URL), which is the same data an unauthenticated browser can read from
50
+ // github.com; `/api/updates/desktop` stays gated because only the CONSOLE
51
+ // asks it, and the console is behind the login.
52
+ '/api/updates/android',
53
+ '/api/updates/ios',
44
54
  '/api/auth/state',
45
55
  '/api/auth/login',
46
56
  '/api/auth/logout',
@@ -45,7 +45,7 @@ import {
45
45
  resolveConfiguredWorktreeRoot,
46
46
  resolveTargetDirectory,
47
47
  } from '@/server/lib/fs/worktree/location';
48
- import { findBranchInUse, refExists } from '@/server/lib/fs/worktree/list';
48
+ import { findBranchInUse, isWorkTree, refExists } from '@/server/lib/fs/worktree/list';
49
49
  import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
50
50
 
51
51
  /**
@@ -113,8 +113,7 @@ export async function planWorktree(
113
113
  deps: Pick<CreateWorktreeDeps, 'identityFor' | 'dataDir' | 'configuredDirectory'>,
114
114
  ): Promise<{ primaryRoot: string; projectId: string; root: string; name: string; directory: string; branch: string; startRef: string }> {
115
115
  const projectPath = path.resolve(input.projectPath);
116
- const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
117
- if (insideWorkTree.exitCode !== 0 || insideWorkTree.stdout.trim() !== 'true') {
116
+ if (!(await isWorkTree(projectPath))) {
118
117
  throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
119
118
  }
120
119
 
@@ -116,6 +116,19 @@ export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
116
116
  return entries;
117
117
  }
118
118
 
119
+ /**
120
+ * Whether `cwd` is inside a work tree at all.
121
+ *
122
+ * The check a plain directory fails, and the one every caller here needs before
123
+ * it may talk about worktrees: `git worktree list` in a non-repository exits
124
+ * non-zero, so without this a notes folder bound as a workspace reads as a
125
+ * BROKEN repository rather than as one that has none.
126
+ */
127
+ export async function isWorkTree(cwd: string): Promise<boolean> {
128
+ const probe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
129
+ return probe.exitCode === 0 && probe.stdout.trim() === 'true';
130
+ }
131
+
119
132
  /** Whether `branch` is checked out in any worktree of this repository, and where. */
120
133
  export async function findBranchInUse(cwd: string, branch: string): Promise<{ path: string } | null> {
121
134
  const entries = await listWorktrees(cwd);
@@ -23,7 +23,7 @@ import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
23
23
  import { runGit } from '@/server/lib/fs/git-run';
24
24
  import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
25
25
  import { WorktreeError } from '@/server/lib/fs/worktree/errors';
26
- import { listWorktrees } from '@/server/lib/fs/worktree/list';
26
+ import { isWorkTree, listWorktrees } from '@/server/lib/fs/worktree/list';
27
27
 
28
28
  /** Changed + untracked count in `directory`; 0 when git cannot answer. */
29
29
  export async function dirtyCount(directory: string): Promise<number> {
@@ -85,8 +85,8 @@ export async function removeWorktree(
85
85
  const requested = path.resolve(input.worktreePath);
86
86
  const insideManagedRoot = input.managedRoot ? await pathHolds(input.managedRoot, requested) : false;
87
87
 
88
- const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: input.projectPath });
89
- if (insideWorkTree.exitCode !== 0 || insideWorkTree.stdout.trim() !== 'true') {
88
+ const insideWorkTree = await isWorkTree(input.projectPath);
89
+ if (!insideWorkTree) {
90
90
  throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
91
91
  }
92
92
 
@@ -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
+ });