@kabmo-labs/kabmo 0.0.1 → 0.2.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 (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -27,6 +27,8 @@ import {
27
27
  type InstallMethodInfo,
28
28
  } from '@/server/lib/updates/install-method';
29
29
  import { isNewer } from '@/shared/lib/updates/semver';
30
+ import { copyAt, resolveInstalledCopy, type InstalledCopy } from '@/server/lib/updates/installed-cli';
31
+ import { runStep, runSync, tail, type UpdateStep } from '@/server/lib/updates/install-steps';
30
32
 
31
33
  /**
32
34
  * Package root of the running copy — the directory holding its `package.json`.
@@ -37,10 +39,6 @@ import { isNewer } from '@/shared/lib/updates/semver';
37
39
  * checkout. `packageDir()` finds the real one in both.
38
40
  */
39
41
  const DEFAULT_PKG_ROOT = packageDir();
40
- /** A client rebuild is slow, but a hung child is worse. */
41
- const STEP_TIMEOUT_MS = 600_000;
42
- /** Cap on the captured output returned to the console. */
43
- const OUTPUT_LIMIT = 20_000;
44
42
 
45
43
  export interface KabmoVersionInfo {
46
44
  /** Version on disk right now (`package.json`), null when unreadable. */
@@ -62,12 +60,6 @@ export interface InstallContext extends InstallMethodInfo {
62
60
  bunBin: string;
63
61
  }
64
62
 
65
- export interface UpdateStep {
66
- label: string;
67
- cmd: string[];
68
- cwd: string;
69
- }
70
-
71
63
  export interface UpdateOptions {
72
64
  /** Reinstall even when the installed version is already the latest. */
73
65
  force?: boolean;
@@ -97,22 +89,14 @@ export interface KabmoUpdateResult {
97
89
  releaseUrl: string | null;
98
90
  /** Captured command output (tail), for non-streaming callers. */
99
91
  output: string;
100
- }
101
-
102
- function runSync(cmd: string[], cwd?: string): { ok: boolean; out: string } {
103
- try {
104
- const proc = Bun.spawnSync({
105
- cmd,
106
- cwd,
107
- stdin: 'ignore',
108
- stdout: 'pipe',
109
- stderr: 'pipe',
110
- timeout: 15_000,
111
- });
112
- return { ok: proc.exitCode === 0, out: proc.stdout.toString().trim() };
113
- } catch {
114
- return { ok: false, out: '' };
115
- }
92
+ /**
93
+ * The copy that now sits on disk — what a restart must launch.
94
+ *
95
+ * Not the copy that is RUNNING: a rename installs a different package
96
+ * beside it, so respawning from the running directory relaunches the build
97
+ * the update just replaced (measured on the 4.23.1 → 0.0.1 move).
98
+ */
99
+ installed: InstalledCopy | null;
116
100
  }
117
101
 
118
102
  /** Version installed on disk — not the one the running process imported. */
@@ -199,47 +183,6 @@ export function planUpdateSteps({ method, version, pkgRoot, bunBin, packageName
199
183
  ];
200
184
  }
201
185
 
202
- /**
203
- * Drain a child's stdout/stderr, forwarding each chunk to `sink` as it arrives
204
- * and returning the whole decoded text. Exported because the omp update path
205
- * needs the same streaming read: buffering with `new Response(stream).text()`
206
- * emits nothing until the process exits, which is exactly what makes an update
207
- * look hung.
208
- */
209
- export async function pumpStream(stream: ReadableStream<Uint8Array>, sink?: (chunk: string) => void): Promise<string> {
210
- const reader = stream.getReader();
211
- const decoder = new TextDecoder();
212
- let text = '';
213
- for (;;) {
214
- const { done, value } = await reader.read();
215
- if (done) break;
216
- const chunk = decoder.decode(value, { stream: true });
217
- if (chunk.length === 0) continue;
218
- text += chunk;
219
- sink?.(chunk);
220
- }
221
- return text;
222
- }
223
-
224
- async function runStep(step: UpdateStep, onLine?: (chunk: string) => void): Promise<{ ok: boolean; exitCode: number | null; output: string }> {
225
- const proc = Bun.spawn({
226
- cmd: step.cmd,
227
- cwd: step.cwd,
228
- stdin: 'ignore',
229
- stdout: 'pipe',
230
- stderr: 'pipe',
231
- timeout: STEP_TIMEOUT_MS,
232
- env: { ...Bun.env, PAGER: 'cat', FORCE_COLOR: '0' },
233
- });
234
- const [stdout, stderr] = await Promise.all([pumpStream(proc.stdout, onLine), pumpStream(proc.stderr, onLine)]);
235
- const exitCode = await proc.exited;
236
- return { ok: exitCode === 0, exitCode, output: `${stdout}${stderr}`.trim() };
237
- }
238
-
239
- function tail(text: string): string {
240
- return text.length > OUTPUT_LIMIT ? `…\n${text.slice(-OUTPUT_LIMIT)}` : text;
241
- }
242
-
243
186
  /**
244
187
  * Bring this install to the newest release. Never throws: every failure mode
245
188
  * (no release, unsupported install, dirty work tree, failing step) comes back
@@ -260,6 +203,7 @@ export async function updateKabmo({ force = false, pkgRoot = DEFAULT_PKG_ROOT, o
260
203
  version: null,
261
204
  releaseUrl: null,
262
205
  output: '',
206
+ installed: null,
263
207
  } satisfies Omit<KabmoUpdateResult, 'success' | 'message'>;
264
208
  const fail = (message: string, patch: Partial<KabmoUpdateResult> = {}): KabmoUpdateResult => ({
265
209
  success: false,
@@ -336,10 +280,18 @@ export async function updateKabmo({ force = false, pkgRoot = DEFAULT_PKG_ROOT, o
336
280
  }
337
281
  }
338
282
 
283
+ // Read back AFTER the steps: a global install puts the package where the
284
+ // installer decided, and a checkout IS the package. A null here leaves the
285
+ // restart on this process's own copy, which is the honest fallback.
286
+ const installed = context.method === 'git'
287
+ ? copyAt(root)
288
+ : resolveInstalledCopy(info.package ?? KABMO_PACKAGE, context.bunBin) ?? copyAt(root);
289
+
339
290
  return {
340
291
  success: true,
341
292
  ...base,
342
293
  ...resolved,
294
+ installed,
343
295
  updated: true,
344
296
  version: info.latest,
345
297
  message: info.renamed
@@ -0,0 +1,222 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The copy an update INSTALLED, and how a restart reaches it.
8
+ *
9
+ * The bug these pin is a rename, not a version bump: `bun add -g
10
+ * @kabmo-labs/kabmo` installs a second package beside `ompchamber`, so a
11
+ * restart resolved from the RUNNING copy relaunched the build the update just
12
+ * replaced — measured on the 4.23.1 → 0.0.1 move, where the log showed
13
+ * `.../node_modules/ompchamber/src/cli/ompchamber.js restart --port 3001` and
14
+ * the console came back as the old version.
15
+ *
16
+ * Three facts are pinned here, each of which failed independently:
17
+ *
18
+ * 1. The CLI file comes from the package's own `bin` map, so a copy that names
19
+ * its bin something other than `kabmo.js` still resolves.
20
+ * 2. A copy is found through Bun's global layout, asked of the installer.
21
+ * 3. The credential marker follows the TARGET package's spelling, because the
22
+ * rename renamed the variable too.
23
+ */
24
+
25
+ import { describe, expect, test } from 'bun:test';
26
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ import { copyAt, globalNodeModules, nodeModulesForBinDir, resolveInstalledCopy } from '@/server/lib/updates/installed-cli';
31
+ import { restartInvocation, scheduleSelfRestart } from '@/server/lib/lifecycle/restart';
32
+ import { writeInstanceRecord } from '@/server/lib/lifecycle/instance';
33
+ import { executableStub } from '@/test-support/executable-stub';
34
+
35
+ function fixture(manifest: Record<string, unknown>, files: string[]): string {
36
+ const root = mkdtempSync(join(tmpdir(), 'installed-cli-'));
37
+ writeFileSync(join(root, 'package.json'), JSON.stringify(manifest));
38
+ for (const file of files) {
39
+ mkdirSync(join(root, file, '..'), { recursive: true });
40
+ writeFileSync(join(root, file), '#!/usr/bin/env bun\n');
41
+ }
42
+ return root;
43
+ }
44
+
45
+ describe('copyAt', () => {
46
+ test('resolves the current package through its own bin', () => {
47
+ const root = fixture(
48
+ { name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } },
49
+ ['src/cli/kabmo.js'],
50
+ );
51
+ try {
52
+ const copy = copyAt(root);
53
+ expect(copy?.packageName).toBe('@kabmo-labs/kabmo');
54
+ expect(copy?.bin).toBe('kabmo');
55
+ // The file name is the package's own, never a guess.
56
+ expect(copy?.cli.endsWith(join('src', 'cli', 'kabmo.js'))).toBe(true);
57
+ } finally {
58
+ rmSync(root, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('the bridge release registers both names, and its own one wins', () => {
63
+ // `ompchamber@4.23.x` ships both bins; resolving to whichever key came
64
+ // first would restart a renamed install through the legacy spelling.
65
+ const root = fixture(
66
+ { name: 'ompchamber', bin: { kabmo: './src/cli/kabmo.js', ompchamber: './src/cli/ompchamber.js' } },
67
+ ['src/cli/kabmo.js', 'src/cli/ompchamber.js'],
68
+ );
69
+ try {
70
+ expect(copyAt(root)?.bin).toBe('ompchamber');
71
+ } finally {
72
+ rmSync(root, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test('a string bin is registered under the unscoped package name', () => {
77
+ const root = fixture({ name: '@kabmo-labs/kabmo', bin: './bin/kabmo.js' }, ['bin/kabmo.js']);
78
+ try {
79
+ expect(copyAt(root)).toMatchObject({ bin: 'kabmo', packageName: '@kabmo-labs/kabmo' });
80
+ } finally {
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ test('a declared bin that is not on disk is not a copy', () => {
86
+ const root = fixture({ name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } }, []);
87
+ try {
88
+ expect(copyAt(root)).toBeNull();
89
+ } finally {
90
+ rmSync(root, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('a directory with no manifest is not a copy', () => {
95
+ const root = mkdtempSync(join(tmpdir(), 'installed-cli-empty-'));
96
+ try {
97
+ expect(copyAt(root)).toBeNull();
98
+ } finally {
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('nodeModulesForBinDir', () => {
105
+ test('the packages sit one layout step from the bin directory', () => {
106
+ // `<BUN_INSTALL>/bin` → `<BUN_INSTALL>/install/global/node_modules`, the
107
+ // shape `detectInstallMethod` recognizes as a Bun global install. Built
108
+ // with `join`, not written POSIX: `node:path` emits the PLATFORM separator,
109
+ // so a literal expectation fails on Windows with the same path spelled
110
+ // `\home\u\...` (measured on the Windows CI leg).
111
+ const installRoot = join(tmpdir(), 'kabmo-bun-install');
112
+ expect(nodeModulesForBinDir(join(installRoot, 'bin')))
113
+ .toBe(join(installRoot, 'install', 'global', 'node_modules'));
114
+ });
115
+ });
116
+
117
+ describe('globalNodeModules', () => {
118
+ // A COMPILED stub, not a shell script: `Bun.spawn` cannot start a script on
119
+ // Windows (only `.com`/`.exe` are tried), and this suite runs there. The path
120
+ // is baked into the source rather than passed through the environment,
121
+ // because a child does not inherit a `Bun.env` assignment made in-process —
122
+ // and baking it keeps the source constant, so the compiled fixture is cached.
123
+ const binDir = join(tmpdir(), 'kabmo-fake-bun-bin');
124
+ const stubSource = `console.log(${JSON.stringify(binDir)});\n`;
125
+
126
+ test('asks the installer and maps its answer', async () => {
127
+ mkdirSync(binDir, { recursive: true });
128
+ const bun = await executableStub(stubSource, 'fake-bun-pm-bin');
129
+ try {
130
+ expect(globalNodeModules(bun)).toBe(nodeModulesForBinDir(binDir));
131
+ } finally {
132
+ rmSync(binDir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test('a package that is not installed resolves to null', async () => {
137
+ const bun = await executableStub('process.exit(1);\n', 'fake-bun-no-answer');
138
+ expect(resolveInstalledCopy('@kabmo-labs/definitely-absent', bun)).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe('restartInvocation', () => {
143
+ test('runs the installed copy and names its credential marker', () => {
144
+ const root = fixture(
145
+ { name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } },
146
+ ['src/cli/kabmo.js'],
147
+ );
148
+ try {
149
+ const invocation = restartInvocation(3001, root);
150
+ expect(invocation?.cmd.slice(-3)).toEqual(['restart', '--port', '3001']);
151
+ expect(invocation?.cmd[1].endsWith(join('src', 'cli', 'kabmo.js'))).toBe(true);
152
+ // The renamed build reads KABMO_PASSWORD_HANDOVER; setting only the old
153
+ // spelling would hand it a payload it never looks for.
154
+ expect(invocation?.handoverEnv).toBe('KABMO_PASSWORD_HANDOVER');
155
+ expect(invocation?.root).toBe(root);
156
+ } finally {
157
+ rmSync(root, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test('a pre-rename copy keeps the old marker', () => {
162
+ const root = fixture(
163
+ { name: 'ompchamber', bin: { ompchamber: './src/cli/ompchamber.js' } },
164
+ ['src/cli/ompchamber.js'],
165
+ );
166
+ try {
167
+ expect(restartInvocation(3001, root)?.handoverEnv).toBe('OMPCHAMBER_PASSWORD_HANDOVER');
168
+ } finally {
169
+ rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('a root with no runnable copy yields no invocation', () => {
174
+ const root = mkdtempSync(join(tmpdir(), 'installed-cli-empty-'));
175
+ try {
176
+ expect(restartInvocation(3001, root)).toBeNull();
177
+ } finally {
178
+ rmSync(root, { recursive: true, force: true });
179
+ }
180
+ });
181
+ });
182
+
183
+ describe('scheduleSelfRestart target', () => {
184
+ test('consults the INSTALLED copy, not the one this process runs from', () => {
185
+ // A rename installs a second package beside the running one, so restarting
186
+ // from this process's own root relaunches the build the update replaced.
187
+ // The refusal names the root it consulted, which is how this is observable
188
+ // without arming a spawn.
189
+ const dataDir = mkdtempSync(join(tmpdir(), 'restart-target-'));
190
+ const previous = Bun.env.KABMO_DATA_DIR;
191
+ Bun.env.KABMO_DATA_DIR = dataDir;
192
+ writeInstanceRecord({
193
+ pid: process.pid,
194
+ port: 4204,
195
+ host: 'localhost',
196
+ mode: 'prod',
197
+ launchMode: 'daemon',
198
+ tray: false,
199
+ tls: false,
200
+ auth: false,
201
+ startedAt: '2026-10-01T10:00:00.000Z',
202
+ version: '0.0.1',
203
+ });
204
+ // A root with no runnable copy: the plan refuses and says WHICH root.
205
+ const installedRoot = mkdtempSync(join(tmpdir(), 'installed-copy-'));
206
+ try {
207
+ const plan = scheduleSelfRestart('update', {
208
+ root: installedRoot,
209
+ packageName: '@kabmo-labs/kabmo',
210
+ bin: 'kabmo',
211
+ cli: join(installedRoot, 'src', 'cli', 'kabmo.js'),
212
+ });
213
+ expect(plan.restarting).toBe(false);
214
+ expect(plan.message).toContain(installedRoot);
215
+ } finally {
216
+ if (previous === undefined) delete Bun.env.KABMO_DATA_DIR;
217
+ else Bun.env.KABMO_DATA_DIR = previous;
218
+ rmSync(installedRoot, { recursive: true, force: true });
219
+ rmSync(dataDir, { recursive: true, force: true });
220
+ }
221
+ });
222
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Where an installed copy of the product lives on disk, and which CLI file
8
+ * restarts it.
9
+ *
10
+ * The restart after an update must launch the copy the update INSTALLED, not
11
+ * the one that is running. That distinction did not exist until the rename:
12
+ * `bun add -g @kabmo-labs/kabmo` installs a DIFFERENT package beside
13
+ * `ompchamber`, so a server that resolved the restart from its own directory
14
+ * kept respawning the pre-rename build — measured on the 4.23.1 → 0.0.1 move,
15
+ * where the log read `.../node_modules/ompchamber/src/cli/ompchamber.js restart
16
+ * --port 3001` and the console came back as 4.23.1 while the update reported
17
+ * success. The same mistake returns in the other direction for a later rename,
18
+ * which is why the rule lives here rather than in a comment.
19
+ *
20
+ * The CLI file comes from the package's own `bin` map, never from a guessed
21
+ * filename: a package ships whatever it registers, so a hard-coded
22
+ * `kabmo.js` finds nothing in a copy that names its bin something else.
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { dirname, join, resolve } from 'node:path';
28
+
29
+ import { resolveBunBin } from '@/server/lib/lifecycle/bun';
30
+
31
+ export interface InstalledCopy {
32
+ /** Package root: the directory holding this copy's `package.json` and `src/`. */
33
+ root: string;
34
+ /** `name` from that `package.json` (`ompchamber` before the rename, `@kabmo-labs/kabmo` after). */
35
+ packageName: string;
36
+ /** The bin key this copy registers — the name a user types. */
37
+ bin: string;
38
+ /** Absolute path to that bin's script. */
39
+ cli: string;
40
+ }
41
+
42
+ interface Manifest {
43
+ name?: unknown;
44
+ bin?: unknown;
45
+ }
46
+
47
+ function readManifest(root: string): Manifest | null {
48
+ try {
49
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as Manifest;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** `@kabmo-labs/kabmo` → `kabmo`: the name a package's own bin is registered under. */
56
+ function unscoped(name: string): string {
57
+ return name.slice(name.lastIndexOf('/') + 1);
58
+ }
59
+
60
+ function binEntries(manifest: Manifest): Array<[string, string]> {
61
+ const { bin } = manifest;
62
+ // A string `bin` is registered under the package's own (unscoped) name.
63
+ if (typeof bin === 'string' && bin.length > 0) {
64
+ const name = typeof manifest.name === 'string' ? unscoped(manifest.name) : '';
65
+ return name.length > 0 ? [[name, bin]] : [];
66
+ }
67
+ if (!bin || typeof bin !== 'object') return [];
68
+ return Object.entries(bin as Record<string, unknown>).flatMap(([key, value]) =>
69
+ typeof value === 'string' && value.length > 0 ? [[key, value] as [string, string]] : [],
70
+ );
71
+ }
72
+
73
+ /**
74
+ * The copy rooted at `root`, or null when no package (or no runnable bin) is
75
+ * there.
76
+ *
77
+ * The bin is preferred by NAME — `@kabmo-labs/kabmo` → `kabmo`, `ompchamber` →
78
+ * `ompchamber` — so a package that registers several scripts (the bridge
79
+ * release registers both) resolves to its own CLI instead of whichever key
80
+ * happens to come first.
81
+ */
82
+ export function copyAt(root: string): InstalledCopy | null {
83
+ const resolved = resolve(root);
84
+ const manifest = readManifest(resolved);
85
+ if (!manifest) return null;
86
+
87
+ const packageName = typeof manifest.name === 'string' ? manifest.name : '';
88
+ const entries = binEntries(manifest);
89
+ const own = unscoped(packageName);
90
+ const ordered = [...entries.filter(([bin]) => bin === own), ...entries.filter(([bin]) => bin !== own)];
91
+
92
+ for (const [bin, script] of ordered) {
93
+ const cli = resolve(resolved, script);
94
+ if (existsSync(cli)) return { root: resolved, packageName, bin, cli };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The package directory behind a Bun bin directory.
101
+ *
102
+ * `bun pm bin -g` answers `<BUN_INSTALL>/bin`, and the packages sit one layout
103
+ * step away — the same `install/global/node_modules` shape
104
+ * `detectInstallMethod` matches. Named rather than inlined because the step is
105
+ * the one fact this module exists to get right.
106
+ */
107
+ export function nodeModulesForBinDir(binDir: string): string {
108
+ return join(dirname(binDir), 'install', 'global', 'node_modules');
109
+ }
110
+
111
+ /**
112
+ * Bun's global `node_modules`, asked from the installer itself.
113
+ *
114
+ * `BUN_INSTALL` is the fallback for a Bun too old to answer `bun pm bin -g`; a
115
+ * host with neither still resolves the documented default, so the caller's own
116
+ * existence check decides.
117
+ */
118
+ export function globalNodeModules(bunBin: string = resolveBunBin()): string {
119
+ try {
120
+ const probe = Bun.spawnSync({
121
+ cmd: [bunBin, 'pm', 'bin', '-g'],
122
+ stdin: 'ignore',
123
+ stdout: 'pipe',
124
+ stderr: 'ignore',
125
+ timeout: 15_000,
126
+ });
127
+ const binDir = probe.exitCode === 0 ? probe.stdout.toString().trim() : '';
128
+ if (binDir.length > 0) return nodeModulesForBinDir(binDir);
129
+ } catch {
130
+ // Fall through to the environment, then to the documented default.
131
+ }
132
+ const installRoot = (Bun.env.BUN_INSTALL ?? '').trim();
133
+ return join(installRoot.length > 0 ? resolve(installRoot) : join(homedir(), '.bun'), 'install', 'global', 'node_modules');
134
+ }
135
+
136
+ /** The global copy of `packageName`, or null when it is not installed. */
137
+ export function resolveInstalledCopy(packageName: string, bunBin: string = resolveBunBin()): InstalledCopy | null {
138
+ return copyAt(join(globalNodeModules(bunBin), packageName));
139
+ }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { invalidateOmpCliCache, resolveOmpBin } from '@/server/lib/omp/core/cli';
18
18
  import { fetchNpmLatest, OMP_PACKAGE } from '@/server/lib/updates/npm';
19
- import { pumpStream } from '@/server/lib/updates/install';
19
+ import { pumpStream } from '@/server/lib/updates/install-steps';
20
20
  import { isNewer } from '@/shared/lib/updates/semver';
21
21
  import type { UpdateTargetInfo } from '@/shared/types/updates';
22
22
 
@@ -15,6 +15,7 @@ import { readSettingsBlob } from '@/shared/lib/settings/keys';
15
15
  import { resolveTheme } from '@/shared/lib/theme/catalog';
16
16
  import { windowChromeColor } from '@/shared/lib/theme/chrome';
17
17
  import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
18
+ import { androidShellVersion } from '@/shared/lib/updates/shell-ua';
18
19
 
19
20
  /** HTML-escape a string for interpolation into the failure page's `<pre>`. */
20
21
  function escapeHtmlText(value: string): string {
@@ -153,7 +154,13 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
153
154
  // downgrade, the retired `noir` alias) renders the default palette instead of
154
155
  // an unstyled page.
155
156
  const theme = resolveTheme(settingsBlob.theme);
156
- const initialIsMobile = MOBILE_UA.test(request.headers.get('user-agent') ?? '');
157
+ const userAgent = request.headers.get('user-agent') ?? '';
158
+ const initialIsMobile = MOBILE_UA.test(userAgent);
159
+ // Which APK is asking, when the request came from the Android shell. Read
160
+ // HERE because the shell is rendered per request and the client's download
161
+ // button needs the answer before its first paint: a fetch after hydration
162
+ // would draw the button late, or draw it and then remove it.
163
+ const shellVersion = androidShellVersion(userAgent);
157
164
 
158
165
  const rendered = await renderShell();
159
166
  if (!rendered.ok) return shellUnavailableResponse(rendered.reason);
@@ -165,7 +172,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
165
172
  // questions: "this browser has a session" versus "there is a session to have".
166
173
  // A Kabmo running open answers `authenticated: true` for every visitor, so
167
174
  // the navbar cannot use it to decide whether a sign-out control applies.
168
- const bootstrap = JSON.stringify({ initialIsMobile, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
175
+ const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
169
176
  // The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
170
177
  // a second tag: with both present Chrome reads the first one, so an appended
171
178
  // tag would leave a dark theme painting light browser chrome.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * POST /api/folders/batch-delete — unregister several workspaces in one request.
8
+ *
9
+ * Same semantics as `POST /api/folders/:folderId/delete`, which is what the
10
+ * workspace's own `⋯` menu performs: the sidebar registration goes, and a bound
11
+ * folder is tombstoned so the omp-project sync cannot re-add it on the next
12
+ * boot. Transcript files are NOT touched — removing a workspace has never meant
13
+ * removing the sessions on disk, and the sessions are separately selectable in
14
+ * the sidebar's session mode when that IS what the user wants.
15
+ *
16
+ * One request rather than N because a batch delete is one user action, and the
17
+ * confirmation dialog should report one summary instead of N answers.
18
+ */
19
+
20
+ import { json } from '@/server/lib/remix-compat';
21
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
22
+ import { methodNotAllowed, parseJsonBody } from '@/server/lib/route-adapter';
23
+ import { getDb } from '@/server/db.server';
24
+ import { deleteWorkspaceFolder } from '@/shared/lib/workspace/project-settings';
25
+ import { syncDiscoveryRootsWatch } from '@/server/lib/omp/config/roots-watch.server';
26
+ import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
27
+
28
+ const MAX_BATCH = 200;
29
+
30
+ export async function batchDeleteFolders({ request }: ActionFunctionArgs) {
31
+ if (request.method !== 'POST') {
32
+ return methodNotAllowed({ request, params: {} });
33
+ }
34
+
35
+ const parsed = await parseJsonBody<{ ids?: unknown }>(request);
36
+ if (!parsed.ok) return json({ error: 'Invalid JSON body' }, { status: 400 });
37
+
38
+ const raw = parsed.body.ids;
39
+ if (!Array.isArray(raw) || raw.some((id) => typeof id !== 'string' || id.length === 0)) {
40
+ return json({ error: 'ids must be an array of workspace ids' }, { status: 400 });
41
+ }
42
+ const ids = [...new Set(raw as string[])];
43
+ if (ids.length === 0) return json({ error: 'No workspaces selected' }, { status: 400 });
44
+ if (ids.length > MAX_BATCH) {
45
+ return json({ error: `At most ${MAX_BATCH} workspaces can be deleted at once.` }, { status: 400 });
46
+ }
47
+
48
+ const db = await getDb();
49
+ const removed: string[] = [];
50
+ const refused: { id: string; error: string; code?: string }[] = [];
51
+
52
+ for (const id of ids) {
53
+ // `deleteWorkspaceFolder` answers false for an id that is not a folder, and
54
+ // takes the id as a bound parameter — there is no path built from it here.
55
+ const deleted = await deleteWorkspaceFolder(db, id);
56
+ if (deleted) removed.push(id);
57
+ else refused.push({ id, error: 'Workspace not found', code: 'not_found' });
58
+ }
59
+
60
+ // One watcher reconcile and one republish for the whole batch: the removed
61
+ // folders' `.omp` roots leave scope together, and the sidebar re-reads once.
62
+ if (removed.length > 0) {
63
+ void syncDiscoveryRootsWatch();
64
+ emitRealtimeSignal('sidebar-structure');
65
+ }
66
+
67
+ return json({ success: refused.length === 0, removed, refused });
68
+ }
@@ -9,9 +9,13 @@ import {
9
9
  toggleFolder,
10
10
  updateFolderSettings,
11
11
  } from '@/server/routes/folders/folder';
12
+ import { batchDeleteFolders } from '@/server/routes/folders/batch-delete';
12
13
 
13
14
  export const foldersBindings: HandlerBinding[] = [
14
15
  ...bindingsFor(foldersCreate, '/api/folders'),
16
+ // Static segment, registered before `:folderId/*` for the same reason the
17
+ // sessions batch route is: an id route must not swallow it.
18
+ { method: 'POST', path: '/api/folders/batch-delete', handler: batchDeleteFolders },
15
19
  ...actionBindings(foldersClone.action, '/api/folders/clone'),
16
20
  ...actionBindings(foldersMkdir.action, '/api/folders/mkdir'),
17
21
  ...actionBindings(pinFolder, '/api/folders/:folderId/pin'),
@@ -95,12 +95,9 @@ export async function browseDirectories({ request }: LoaderFunctionArgs) {
95
95
  });
96
96
  }
97
97
 
98
- /**
99
- * Names never listed in the Files panel: dependency/build output and the git
100
- * object database. Skipped for being noise, not for being hidden — every other
101
- * entry is listed, dot-prefixed files and folders included.
102
- */
103
-
98
+ // No name is filtered: `lib/fs/listing.ts` (shared with the realtime topic)
99
+ // lists every entry, and git-ignored ones are dimmed rather than dropped.
100
+ //
104
101
  // Lazy listing: return only the immediate children of a directory. Folders are
105
102
  // emitted with `children: null` meaning "not loaded yet" so the client can
106
103
  // fetch them on demand — directories are NOT recursed here (keeps the initial
@@ -143,8 +140,26 @@ export async function listDirectory({ request }: LoaderFunctionArgs) {
143
140
  /** Hard cap on emitted files so a huge tree cannot stall the request. */
144
141
  const MAX_FILES = 3000;
145
142
 
146
- /** Directory names that are never recursed into. */
147
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', 'coverage']);
143
+ /**
144
+ * Directory names the RECURSIVE walk does not descend into.
145
+ *
146
+ * This is a budget guard, not the Files panel's noise table (there is none any
147
+ * more — `lib/fs/listing.ts` lists every entry). `/api/fs/list` returns a flat
148
+ * file index capped at `MAX_FILES` for the composer's `@` picker, and
149
+ * descending into a dependency tree spends that whole budget inside
150
+ * `node_modules` — leaving the picker with no project file in it. The tree
151
+ * panel is unaffected: it lists lazily, one directory per request, so a folder
152
+ * is only walked when someone expands it.
153
+ */
154
+ const SKIP_DIRS: Record<string, true> = {
155
+ node_modules: true,
156
+ '.git': true,
157
+ dist: true,
158
+ build: true,
159
+ '.next': true,
160
+ '.cache': true,
161
+ coverage: true,
162
+ };
148
163
 
149
164
  interface ListFileEntry {
150
165
  name: string;
@@ -172,7 +187,7 @@ async function walk(dir: string, baseDir: string, out: ListFileEntry[]): Promise
172
187
  if (entry.name.startsWith('.')) continue;
173
188
 
174
189
  if (entry.isDirectory()) {
175
- if (SKIP_DIRS.has(entry.name)) continue;
190
+ if (SKIP_DIRS[entry.name]) continue;
176
191
  await walk(path.join(dir, entry.name), baseDir, out);
177
192
  continue;
178
193
  }