@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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `sidebar:activity` resolver, against a real sessions directory.
8
+ *
9
+ * The rules under test are the ones that keep this payload small enough to be
10
+ * republished per tool call:
11
+ *
12
+ * - it carries ONLY sessions with a run in flight — every other row keeps the
13
+ * stamp the structure gave it, which is why the structure does not have to
14
+ * be re-scanned;
15
+ * - a live run with no transcript yet contributes nothing (a fresh spawn:
16
+ * omp writes the file only when the first assistant message settles);
17
+ * - the stamp it reports is the newest ENTRY's timestamp, the same quantity
18
+ * the structure ships as `updated_at`, so the client can compare them;
19
+ * - the subagent flag is reported alongside, because a subagent's transcript
20
+ * appears mid-run and lights the parent row's roster chevron.
21
+ *
22
+ * Driven through `localRuns()` rather than a stubbed registry: that set is the
23
+ * real "a run is in flight" source (the mock SSE stream uses it too), so the
24
+ * test exercises the same read `loadSidebarStatus` does.
25
+ */
26
+
27
+ import { afterAll, afterEach, describe, expect, test } from 'bun:test';
28
+ import fs from 'fs';
29
+ import * as path from 'path';
30
+
31
+ import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
32
+ import { localRuns } from '@/server/lib/omp/session/local-runs.server';
33
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
34
+ import { SESSION_TITLE_SLOT_BYTES } from '@/server/lib/omp/session/files';
35
+
36
+ const tempDirs: string[] = [];
37
+ const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
38
+
39
+ /**
40
+ * Point the agent dir at a scratch tree, because `findSessionFileById` resolves
41
+ * through the real sessions root. `PI_CODING_AGENT_DIR` is read per call, so no
42
+ * module reload is needed — but the file caches are process-wide and keyed by
43
+ * path, so they are cleared before and after.
44
+ */
45
+ function useAgentDir(): string {
46
+ const dir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'kabmo-activity-'));
47
+ tempDirs.push(dir);
48
+ process.env.PI_CODING_AGENT_DIR = dir;
49
+ clearSessionFileCaches();
50
+ return dir;
51
+ }
52
+
53
+ /** A title slot line, exactly as omp writes it, so the scan reads a real file. */
54
+ function titleSlot(title: string): string {
55
+ const raw = JSON.stringify({ type: 'title', title });
56
+ return raw + ' '.repeat(Math.max(0, SESSION_TITLE_SLOT_BYTES - 1 - raw.length));
57
+ }
58
+
59
+ interface SessionFixture {
60
+ id: string;
61
+ entries?: Array<{ type: string; timestamp?: string }>;
62
+ subagents?: string[];
63
+ }
64
+
65
+ /**
66
+ * Write one session file under `<agentDir>/sessions/<project>/`.
67
+ *
68
+ * The entries are minimal but carry the field the scan dates the file by (the
69
+ * newest entry's own `timestamp`), which is the whole quantity under test.
70
+ */
71
+ function writeSession(agentDir: string, fixture: SessionFixture): string {
72
+ const projectDir = path.join(agentDir, 'sessions', '-tmp-project');
73
+ fs.mkdirSync(projectDir, { recursive: true });
74
+ const file = path.join(projectDir, `2026-10-24T00-00-00-000Z_${fixture.id}.jsonl`);
75
+ const lines = [
76
+ titleSlot(`Session ${fixture.id}`),
77
+ JSON.stringify({ type: 'session', id: fixture.id, cwd: '/tmp/project', timestamp: '2026-10-24T00:00:00.000Z' }),
78
+ ...(fixture.entries ?? []).map((entry) => JSON.stringify(entry)),
79
+ ];
80
+ fs.writeFileSync(file, `${lines.join('\n')}\n`);
81
+
82
+ if (fixture.subagents?.length) {
83
+ // A sibling directory named after the session file, holding one .jsonl per
84
+ // subagent — the shape `sessionHasSubagents` reads.
85
+ const sibling = file.replace(/\.jsonl$/, '');
86
+ fs.mkdirSync(sibling, { recursive: true });
87
+ for (const id of fixture.subagents) {
88
+ fs.writeFileSync(path.join(sibling, `${id}.jsonl`), `${JSON.stringify({ type: 'message' })}\n`);
89
+ }
90
+ }
91
+ clearSessionFileCaches();
92
+ return file;
93
+ }
94
+
95
+ afterEach(() => {
96
+ localRuns().clear();
97
+ clearSessionFileCaches();
98
+ });
99
+
100
+ afterAll(() => {
101
+ if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
102
+ else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
103
+ for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
104
+ });
105
+
106
+ describe('loadSidebarActivity', () => {
107
+ test('carries nothing when no run is in flight', async () => {
108
+ const agentDir = useAgentDir();
109
+ writeSession(agentDir, { id: 'idle', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
110
+ expect(await loadSidebarActivity()).toEqual({});
111
+ });
112
+
113
+ test('reports the newest entry timestamp of a session with a live run', async () => {
114
+ const agentDir = useAgentDir();
115
+ writeSession(agentDir, {
116
+ id: 'live',
117
+ entries: [
118
+ { type: 'message', timestamp: '2026-10-24T01:00:00.000Z' },
119
+ { type: 'message', timestamp: '2026-10-24T01:05:00.000Z' },
120
+ ],
121
+ });
122
+ localRuns().add('live');
123
+
124
+ const activity = await loadSidebarActivity();
125
+ expect(Object.keys(activity)).toEqual(['live']);
126
+ expect(activity.live?.updatedAt).toBe('2026-10-24T01:05:00.000Z');
127
+ });
128
+
129
+ test('never reports a session without a run, however recent its transcript', async () => {
130
+ const agentDir = useAgentDir();
131
+ writeSession(agentDir, { id: 'other', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
132
+ writeSession(agentDir, { id: 'live', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
133
+ localRuns().add('live');
134
+
135
+ expect(Object.keys(await loadSidebarActivity())).toEqual(['live']);
136
+ });
137
+
138
+ test('omits a live run whose transcript does not exist yet', async () => {
139
+ // A fresh spawn: omp writes the session file only when the first assistant
140
+ // message settles, so there is no stamp to report. Its row is the client's
141
+ // pending row until the scan finds the file.
142
+ useAgentDir();
143
+ localRuns().add('not-written-yet');
144
+ expect(await loadSidebarActivity()).toEqual({});
145
+ });
146
+
147
+ test('reports the subagent flag, which moves mid-run', async () => {
148
+ const agentDir = useAgentDir();
149
+ writeSession(agentDir, {
150
+ id: 'with-sub',
151
+ entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }],
152
+ subagents: ['Sleeper'],
153
+ });
154
+ writeSession(agentDir, { id: 'plain', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
155
+ localRuns().add('with-sub');
156
+ localRuns().add('plain');
157
+
158
+ const activity = await loadSidebarActivity();
159
+ expect(activity['with-sub']?.hasSubagents).toBe(true);
160
+ expect(activity.plain?.hasSubagents).toBe(false);
161
+ });
162
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `sidebar:activity` resolver: the two row fields a RUNNING session can
8
+ * still move between turn boundaries.
9
+ *
10
+ * Split out of `sidebar-data.server.ts` (which owns the structure and the
11
+ * status) because it answers a different question at a different cadence: the
12
+ * structure is re-scanned at turn boundaries, while this is resolved on every
13
+ * tool call — and its whole reason to exist is that the structure must NOT be.
14
+ *
15
+ * A run appends to its transcript on every tool call, so its row's age and its
16
+ * `LATEST_SESSION` position move mid-run. Republishing the structure for that
17
+ * cost 216 KB over 731 sessions per tool call (measured on the author's
18
+ * install, against 87 bytes for one live entry here), which is what made a
19
+ * phone's stream feel slow. Only a RUNNING session can move between boundaries,
20
+ * so only those are carried.
21
+ *
22
+ * A live run with no session FILE yet (a fresh spawn: omp writes it only when
23
+ * the first assistant message settles) contributes nothing — there is no stamp
24
+ * to report, and its sidebar row is the client's pending row until the scan
25
+ * finds the file.
26
+ */
27
+
28
+ import { isMockMode } from '@/server/mock.server';
29
+ import { getLiveRunSessionIds } from '@/server/lib/omp/rpc/session-registry';
30
+ import { findSessionFileById } from '@/server/lib/omp/session/locator';
31
+ import { scanSessionInfoCached } from '@/server/lib/omp/session/files';
32
+ import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
33
+ import type { SidebarActivityPayload } from '@/shared/types/realtime';
34
+
35
+ export async function loadSidebarActivity(): Promise<SidebarActivityPayload> {
36
+ const activity: SidebarActivityPayload = {};
37
+ // MOCK answers nothing, and that is not a shortcut: the simulated stream is
38
+ // an SSE route, not an omp child, so no `tool_execution_end` frame ever fires
39
+ // there and there is no between-boundary movement to report. Its rows also
40
+ // read `created_at` (the mock `sessions` table has no `updated_at`), so a
41
+ // `chat_sessions` stamp would be a different quantity under the same name —
42
+ // the one thing this payload must never be.
43
+ if (isMockMode()) return activity;
44
+
45
+ const live = getLiveRunSessionIds();
46
+ if (live.size === 0) return activity;
47
+ const entries = await Promise.all(
48
+ [...live].map(async (sessionId) => {
49
+ const filePath = await findSessionFileById(sessionId);
50
+ if (!filePath) return null;
51
+ const info = await scanSessionInfoCached(filePath);
52
+ if (!info) return null;
53
+ // `modified` is the newest entry's own timestamp, which is what the
54
+ // structure ships as `updated_at` — the same quantity, so the client can
55
+ // compare the two as ISO strings.
56
+ const updatedAt = info.modified.toISOString();
57
+ // The chevron's flag moves mid-run too: a subagent writes its own
58
+ // transcript beside the parent, and the row it lights is the parent's.
59
+ // Cost is a stat plus (on a change) one readdir, and the presence module
60
+ // memoizes on `(last entry, sibling mtime)` — measured 4-8 ms on a cache
61
+ // miss, 0 on a hit.
62
+ const hasSubagents = await sessionHasSubagents(filePath, updatedAt);
63
+ return [sessionId, { updatedAt, hasSubagents }] as const;
64
+ }),
65
+ );
66
+ for (const entry of entries) {
67
+ if (entry) activity[entry[0]] = entry[1];
68
+ }
69
+ return activity;
70
+ }
@@ -10,6 +10,15 @@ import { scanSessionEntries } from '@/server/lib/omp/session/telemetry/scan';
10
10
  * Compute real `SessionContextTelemetry` for a session file (all-zero on
11
11
  * unreadable/empty). `rawLimit` caps materialized raw items (0 = none): the
12
12
  * panel reads full pages from `computeRawMessagesPage` in ./telemetry-raw.ts.
13
+ *
14
+ * The default is 0 — no raw items — and that is load-bearing rather than
15
+ * tidy: this value is published as the `session:<id>:telemetry` topic, which
16
+ * the navbar ring subscribes to for EVERY open session and the server
17
+ * republishes on every tool call. Measured on a 111-message transcript, the
18
+ * full `rawMessages` list (each item carrying its whole entry as
19
+ * `rawPayload`) was **690 KB of a 691 KB snapshot** — 99.9% of every push,
20
+ * per tool call, per tab. Nothing reads it: the panel pages raw items from
21
+ * `/api/telemetry/raw-messages`, and `/api/telemetry/context` strips them.
13
22
  */
14
23
  export async function computeRealSessionTelemetry(
15
24
  filePath: string,
@@ -106,7 +115,10 @@ export async function computeRealSessionTelemetry(
106
115
 
107
116
  if (role === 'user' || role === 'assistant') {
108
117
  messagesCount++;
109
- if (rawLimit === 0 || rawMessages.length < rawLimit) {
118
+ // `rawLimit` is a CAP, so 0 means "none" — the guard must not read 0 as
119
+ // "unlimited", which is what it did and what put the whole transcript on
120
+ // the telemetry topic (see the doc above).
121
+ if (rawLimit > 0 && rawMessages.length < rawLimit) {
110
122
  const isAssistant = role === 'assistant';
111
123
  const snippet = text.slice(0, 70);
112
124
  rawMessages.push({
@@ -152,3 +152,36 @@ describe('computeRealSessionTelemetry context anchor', () => {
152
152
  expect(telemetry.contextUsed).toBe(2200);
153
153
  });
154
154
  });
155
+
156
+ describe('computeRealSessionTelemetry raw items', () => {
157
+ // The default is the TELEMETRY TOPIC's payload, which the navbar ring keeps
158
+ // subscribed for every open session and the server republishes on every tool
159
+ // call. A raw item carries its whole entry as `rawPayload`, so reading 0 as
160
+ // "unlimited" put the entire transcript on that topic — measured 690 KB of a
161
+ // 691 KB snapshot on a 111-message session.
162
+ test('materializes no raw items by default', async () => {
163
+ const file = await writeSession([
164
+ header,
165
+ userMessage('u1', 'first turn'),
166
+ assistantMessage('a1', { usage: { input: 10, output: 5 } }),
167
+ userMessage('u2', 'second turn'),
168
+ assistantMessage('a2', { usage: { input: 20, output: 5 } }),
169
+ ]);
170
+ const telemetry = await computeRealSessionTelemetry(file, 'ses_telemetry');
171
+ expect(telemetry.messagesCount).toBe(4);
172
+ expect(telemetry.rawMessages).toEqual([]);
173
+ });
174
+
175
+ test('caps raw items at a positive limit', async () => {
176
+ const file = await writeSession([
177
+ header,
178
+ userMessage('u1', 'first turn'),
179
+ assistantMessage('a1', { usage: { input: 10, output: 5 } }),
180
+ userMessage('u2', 'second turn'),
181
+ assistantMessage('a2', { usage: { input: 20, output: 5 } }),
182
+ ]);
183
+ const telemetry = await computeRealSessionTelemetry(file, 'ses_telemetry', undefined, 2);
184
+ expect(telemetry.rawMessages).toHaveLength(2);
185
+ expect(telemetry.rawMessages[0]?.id).toBe('u1');
186
+ });
187
+ });
@@ -22,6 +22,16 @@ export type RealtimeSignal =
22
22
  | 'stream-status'
23
23
  /** The sidebar's structure changed (a session or folder was added/removed). */
24
24
  | 'sidebar-structure'
25
+ /**
26
+ * A RUNNING session's transcript grew, so its last-activity stamp moved.
27
+ *
28
+ * Raised per tool call, which is the whole reason it is not
29
+ * `sidebar-structure`: that payload is every folder and session (216 KB over
30
+ * 731 sessions, measured), while only a running session's stamp can move
31
+ * between turn boundaries. Republishing the structure here is what made a
32
+ * phone's stream feel slow — one full list per tool call.
33
+ */
34
+ | 'sidebar-activity'
25
35
  /** An omp child for `sessionId` became reachable, or was torn down. */
26
36
  | 'session-attached'
27
37
  /** A scheduled task was created, edited, paused, deleted or fired. */
@@ -20,6 +20,7 @@ import {
20
20
  TOPIC_PANELS,
21
21
  TOPIC_SCHEDULE,
22
22
  TOPIC_SIDEBAR,
23
+ TOPIC_SIDEBAR_ACTIVITY,
23
24
  TOPIC_SIDEBAR_STATUS,
24
25
  TOPIC_USAGE,
25
26
  TOPIC_WAITING,
@@ -28,10 +29,11 @@ import {
28
29
  sessionTelemetryTopic,
29
30
  sessionTodosTopic,
30
31
  } from '@/shared/lib/realtime/protocol';
31
- import type { SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
32
+ import type { SidebarActivityPayload, SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
32
33
  import { isValidPluginId } from '@/shared/lib/plugins/protocol';
33
34
  import type { TopicDescriptor } from '@/server/lib/realtime/hub.types';
34
35
  import { loadSidebarStatus, loadSidebarStructure } from '@/server/lib/omp/session/sidebar-data.server';
36
+ import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
35
37
  import { sessionDescriptor } from '@/server/lib/realtime/topics/session.server';
36
38
  import { btwDescriptor } from '@/server/lib/realtime/topics/btw.server';
37
39
  import { SESSION_DATA_TOPICS } from '@/server/lib/realtime/topics/session-data.server';
@@ -64,6 +66,7 @@ export const SCOPED_FAMILIES: Record<string, (scope: string) => TopicDescriptor>
64
66
  export const EXACT_TOPICS = new Map<string, TopicDescriptor>([
65
67
  [TOPIC_SIDEBAR, { resolve: async (): Promise<SidebarPayload> => loadSidebarStructure() }],
66
68
  [TOPIC_SIDEBAR_STATUS, { resolve: async (): Promise<SidebarStatusPayload> => loadSidebarStatus() }],
69
+ [TOPIC_SIDEBAR_ACTIVITY, { resolve: async (): Promise<SidebarActivityPayload> => loadSidebarActivity() }],
67
70
  [TOPIC_SCHEDULE, { resolve: () => globalDescriptor(TOPIC_SCHEDULE).resolve(TOPIC_SCHEDULE) }],
68
71
  [TOPIC_USAGE, { resolve: () => globalDescriptor(TOPIC_USAGE).resolve(TOPIC_USAGE) }],
69
72
  [TOPIC_PANELS, { resolve: () => globalDescriptor(TOPIC_PANELS).resolve(TOPIC_PANELS) }],
@@ -18,6 +18,7 @@ import {
18
18
  TOPIC_PANELS,
19
19
  TOPIC_SCHEDULE,
20
20
  TOPIC_SIDEBAR,
21
+ TOPIC_SIDEBAR_ACTIVITY,
21
22
  TOPIC_SIDEBAR_STATUS,
22
23
  TOPIC_USAGE,
23
24
  TOPIC_WAITING,
@@ -29,6 +30,7 @@ import { getRealtimeHub, registerTopics, type TopicDescriptor } from '@/server/l
29
30
  import { onRealtimeSignal } from '@/server/lib/realtime/signals.server';
30
31
  import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
31
32
  import { loadSidebarStatus, loadSidebarStructure } from '@/server/lib/omp/session/sidebar-data.server';
33
+ import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
32
34
  import { EXACT_TOPICS, descriptorFor, isKnownTopicName } from '@/server/lib/realtime/topics/catalog.server';
33
35
 
34
36
  declare global {
@@ -78,6 +80,7 @@ export function initRealtimeTopics(): void {
78
80
  onRealtimeSignal(({ signal, sessionId, root }) => {
79
81
  if (signal === 'stream-status') publishSidebarStatus();
80
82
  else if (signal === 'sidebar-structure') publishSidebarStructure();
83
+ else if (signal === 'sidebar-activity') publishSidebarActivity();
81
84
  else if (signal === 'session-attached' && sessionId) publishSessionState(sessionId);
82
85
  else if (signal === 'schedule-changed') publishTopic(TOPIC_SCHEDULE);
83
86
  else if (signal === 'panels-changed') publishTopic(TOPIC_PANELS);
@@ -244,6 +247,19 @@ export function publishSidebarStatus(): void {
244
247
  schedulePublish(TOPIC_SIDEBAR_STATUS, loadSidebarStatus);
245
248
  }
246
249
 
250
+ /**
251
+ * A RUNNING session's transcript grew: republish its activity stamps.
252
+ *
253
+ * Called per tool call (see `handleToolExecutionEnd`), so it must never be the
254
+ * structure publish: that one invalidates the discovery scan and re-serializes
255
+ * every folder and session. This payload carries one ISO string per live run
256
+ * and re-reads nothing but those files' own summaries (each memoized on
257
+ * `(size, mtimeMs)`, measured 0.1 ms on a cache miss).
258
+ */
259
+ export function publishSidebarActivity(): void {
260
+ schedulePublish(TOPIC_SIDEBAR_ACTIVITY, loadSidebarActivity);
261
+ }
262
+
247
263
  /**
248
264
  * The sidebar's STRUCTURE changed: a session was created, deleted, renamed, a
249
265
  * folder was added or removed, or a JSONL grew a new session.
@@ -12,6 +12,7 @@
12
12
  * version against a shell version.
13
13
  */
14
14
 
15
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
15
16
  import type { ShellKind } from '@/shared/lib/updates/shell-ua';
16
17
 
17
18
  /**
@@ -28,7 +29,13 @@ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
28
29
  * The desktop entries match on the segment AFTER the version rather than on the
29
30
  * whole name, because one release carries several files per platform
30
31
  * (`-mac-arm64.zip` and `-mac-arm64.dmg`, four Linux formats, two arches). Any
31
- * one of them answers the same version, and the download is the release page.
32
+ * one of them answers the same version, which is all `pattern` is for.
33
+ *
34
+ * WHICH file the console hands back is a second question, and `selfUpdate` is
35
+ * the answer: a desktop shell that knows its architecture installs one exact
36
+ * artifact, and a probe that does not know it still gets the release page. Both
37
+ * live in this table so the route, the pages fallback and the tests read one
38
+ * spelling.
32
39
  *
33
40
  * **`legacy` is not dead code.** These names are PARSED to answer "is a newer
34
41
  * shell published", and every release already on GitHub carries the old mobile
@@ -45,47 +52,131 @@ export interface AssetScheme {
45
52
  /** The old mobile spelling, or null for a shell that never had one. */
46
53
  legacy: { prefix: string; extension: string } | null;
47
54
  /**
48
- * Whether the payload's `url` should be the artifact itself. False for a
49
- * per-arch desktop build, where the release page is the only honest link.
55
+ * Whether the payload's `url` should be the artifact itself. True for the
56
+ * mobile pair, whose artifact is the same file on every device.
50
57
  */
51
58
  directDownload: boolean;
59
+ /**
60
+ * The ONE file a desktop shell installs ITSELF from, per architecture.
61
+ *
62
+ * `null` for the mobile pair: an APK is installed by `PackageInstaller` and an
63
+ * IPA cannot be installed at all, so neither is this shell's own business.
64
+ *
65
+ * The desktop entry is deliberately narrower than `pattern`: one release
66
+ * carries several files per platform (`.zip` and `.dmg`, `.tar.gz`/`.deb`/
67
+ * `.rpm`/`.AppImage`), and only ONE of each is something the shell can install
68
+ * without a human opening it. The choice is stated here rather than at the
69
+ * call site so the route, the pages fallback and the tests cannot disagree
70
+ * about which file "the update" means.
71
+ */
72
+ selfUpdate: SelfUpdateScheme | null;
73
+ }
74
+
75
+ /**
76
+ * How one desktop shell's self-update artifact is named.
77
+ *
78
+ * `pattern` captures the version AND the architecture, because the two answers
79
+ * come from the same string: the version is what the console compares against
80
+ * the installed build, and the architecture is what makes this file the right
81
+ * one for the machine asking.
82
+ */
83
+ export interface SelfUpdateScheme {
84
+ /** `Kabmo-<version>-<platform>-<arch><suffix>` → `[name, version, arch]`. */
85
+ pattern: RegExp;
86
+ /** How each canonical arch is spelled in the ASSET name. */
87
+ assetArch: Record<ShellArch, string>;
52
88
  }
53
89
 
54
90
  const VERSION = String.raw`(\d+\.\d+\.\d+)`;
55
91
 
92
+ /** `arm64`/`x64` as macOS and Windows spell them. */
93
+ const SAME_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x64' };
94
+
95
+ /** Linux says `x86_64` where the other two say `x64`. */
96
+ const LINUX_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x86_64' };
97
+
56
98
  export const SHELL_ASSETS: Record<ShellKind, AssetScheme> = {
57
99
  android: {
58
100
  pattern: new RegExp(`^Kabmo-${VERSION}-android\\.apk$`),
59
101
  current: /^Kabmo-\d/,
60
102
  legacy: { prefix: 'kabmo-android-v', extension: '.apk' },
61
103
  directDownload: true,
104
+ selfUpdate: null,
62
105
  },
63
106
  ios: {
64
107
  pattern: new RegExp(`^Kabmo-${VERSION}-ios\\.ipa$`),
65
108
  current: /^Kabmo-\d/,
66
109
  legacy: { prefix: 'kabmo-ios-v', extension: '.ipa' },
67
110
  directDownload: true,
111
+ selfUpdate: null,
68
112
  },
69
113
  macos: {
70
114
  pattern: new RegExp(`^Kabmo-${VERSION}-mac-`),
71
115
  current: /^Kabmo-\d/,
72
116
  legacy: null,
73
117
  directDownload: false,
118
+ // The `.zip`, not the `.dmg`: `ditto -x -k` extracts it without mounting a
119
+ // volume, and the shell has to be able to do that with no human present.
120
+ // Both shapes are published; only one is installable by the app itself.
121
+ selfUpdate: {
122
+ pattern: new RegExp(`^Kabmo-${VERSION}-mac-(arm64|x64)\\.zip$`),
123
+ assetArch: SAME_ARCH,
124
+ },
74
125
  },
75
126
  windows: {
76
127
  pattern: new RegExp(`^Kabmo-${VERSION}-win-`),
77
128
  current: /^Kabmo-\d/,
78
129
  legacy: null,
79
130
  directDownload: false,
131
+ // The INSTALLER, not the `.zip`-shaped publish output: Inno already owns the
132
+ // upgrade semantics (a stable `AppId` is what makes an install an upgrade
133
+ // rather than a second copy), and `/CLOSEAPPLICATIONS` is how it replaces a
134
+ // running exe.
135
+ selfUpdate: {
136
+ pattern: new RegExp(`^Kabmo-${VERSION}-win-(arm64|x64)-setup\\.exe$`),
137
+ assetArch: SAME_ARCH,
138
+ },
80
139
  },
81
140
  linux: {
82
141
  pattern: new RegExp(`^Kabmo-${VERSION}-linux-`),
83
142
  current: /^Kabmo-\d/,
84
143
  legacy: null,
85
144
  directDownload: false,
145
+ // The AppImage, and only it: `.deb`/`.rpm` need root and a package manager,
146
+ // so a shell cannot install them — it says so instead. An AppImage is ONE
147
+ // file the shell can replace atomically and re-exec.
148
+ selfUpdate: {
149
+ pattern: new RegExp(`^Kabmo-${VERSION}-linux-(arm64|x86_64)\\.AppImage$`),
150
+ assetArch: LINUX_ARCH,
151
+ },
86
152
  },
87
153
  };
88
154
 
155
+ /**
156
+ * The self-update artifact for one architecture, if this name is it.
157
+ *
158
+ * Null for a shell that has no self-update, for an arch it does not publish, and
159
+ * for every other file in the release (a `.dmg`, a `.deb`, the OTHER
160
+ * architecture's build). Callers treat null as "not the file this machine
161
+ * installs", which is why the route falls back to the release page rather than
162
+ * guessing.
163
+ */
164
+ export function selfUpdateArtifact(
165
+ kind: ShellKind,
166
+ arch: ShellArch,
167
+ name: string,
168
+ ): { version: string } | null {
169
+ const scheme = SHELL_ASSETS[kind].selfUpdate;
170
+ if (!scheme) return null;
171
+
172
+ const match = scheme.pattern.exec(name);
173
+ if (!match) return null;
174
+ // The captured arch is compared against the ASSET spelling, so a Linux build
175
+ // reporting `x64` matches `…-linux-x86_64.AppImage` and nothing else.
176
+ if (match[2] !== scheme.assetArch[arch]) return null;
177
+ return { version: match[1] ?? '' };
178
+ }
179
+
89
180
  /**
90
181
  * `Kabmo-0.5.0-android.apk` → `0.5.0`, the `.ipa` twin, every desktop spelling,
91
182
  * plus the legacy `kabmo-android-v0.5.0.apk` the older releases still carry.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The shell-release answer cache, and where the GitHub reads go.
8
+ *
9
+ * Split from `shell-release.ts` for two reasons that are not cosmetic.
10
+ *
11
+ * 1. **The key is per SHELL AND per ARCHITECTURE.** A desktop shell asks for the
12
+ * artifact of its own machine, so a single per-kind slot would answer an
13
+ * arm64 window with the x64 installer the moment both hit one console — and
14
+ * the console is routinely shared by a whole team.
15
+ * 2. **The cache outlives a test file.** It lives on `globalThis` so `bun --hot`
16
+ * cannot orphan it, which is also why `resetShellReleaseCache` exists: without
17
+ * that seam the second case in a suite reads the first case's answer and
18
+ * passes for the wrong reason (measured — the "malformed body" case returned
19
+ * the previous test's release).
20
+ */
21
+
22
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
23
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
24
+
25
+ /** How long an answer is reused. */
26
+ export const CACHE_MS = 5 * 60_000;
27
+
28
+ /** One shell's (and, for a desktop, one architecture's) cached answer. */
29
+ export interface CacheSlot<T> {
30
+ entry: T | null;
31
+ at: number;
32
+ }
33
+
34
+ type ShellCacheHost = typeof globalThis & {
35
+ __kabmoShellRelease?: Record<string, CacheSlot<unknown>>;
36
+ };
37
+
38
+ /**
39
+ * The cache key for one question.
40
+ *
41
+ * The arch is part of the KEY rather than a field on the value, so a stale entry
42
+ * can never be read by the wrong machine: a miss is a miss, and there is no
43
+ * comparison left to get wrong.
44
+ */
45
+ export function shellCacheKey(kind: ShellKind, arch: ShellArch | null): string {
46
+ return arch ? `${kind}:${arch}` : kind;
47
+ }
48
+
49
+ /** The cache slot for one key, created on first use. */
50
+ export function cacheSlot<T>(key: string): CacheSlot<T> {
51
+ const host = globalThis as ShellCacheHost;
52
+ host.__kabmoShellRelease ??= {};
53
+ host.__kabmoShellRelease[key] ??= { entry: null, at: 0 };
54
+ return host.__kabmoShellRelease[key] as CacheSlot<T>;
55
+ }
56
+
57
+ /**
58
+ * Drop the cached answer for one shell (or every shell, with no argument).
59
+ *
60
+ * Production has no caller; it is the test seam described above.
61
+ */
62
+ export function resetShellReleaseCache(kind?: ShellKind): void {
63
+ const host = globalThis as ShellCacheHost;
64
+ if (!kind) {
65
+ host.__kabmoShellRelease = {};
66
+ return;
67
+ }
68
+ const prefix = `${kind}:`;
69
+ const slots = host.__kabmoShellRelease ?? {};
70
+ for (const key of Object.keys(slots)) {
71
+ if (key === kind || key.startsWith(prefix)) delete slots[key];
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Where the release read goes.
77
+ *
78
+ * Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
79
+ * test and a rehearsal need to exercise the real parsing against a stub instead
80
+ * of a live GitHub call, and hardcoding the host makes that impossible without
81
+ * intercepting `fetch` globally.
82
+ */
83
+ export function githubApiBase(): string {
84
+ const configured = Bun.env.KABMO_GITHUB_API ?? '';
85
+ return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
86
+ }
87
+
88
+ /**
89
+ * A token, when the operator has one, which lifts the API quota from 60 an hour
90
+ * to 5000 — the difference between the precise read working on a shared address
91
+ * and falling through to the pages every time.
92
+ *
93
+ * Read from the environment rather than a setting: it is a deployment fact, it
94
+ * must never reach the browser, and `kabmo serve` already passes its own
95
+ * environment through.
96
+ */
97
+ export function gitHubHeaders(): Record<string, string> {
98
+ const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
99
+ const headers: Record<string, string> = {
100
+ // GitHub rejects a request with no User-Agent.
101
+ 'user-agent': 'kabmo-console',
102
+ };
103
+ if (token) headers.authorization = `Bearer ${token}`;
104
+ return headers;
105
+ }