@kabmo-labs/kabmo 0.0.1 → 0.1.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 (61) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +323 -323
  4. package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
  5. package/dist/client/static/js/index-yn17t04b.css +1 -0
  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/components/auth/login-screen/index.test.tsx +93 -0
  11. package/src/client/components/auth/login-screen/index.tsx +43 -22
  12. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  13. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  15. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  16. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  17. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  18. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  19. package/src/client/components/common/batch-selection/types.ts +42 -0
  20. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  21. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  22. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  23. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  24. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  25. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  26. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  27. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  28. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  29. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  30. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  31. package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
  32. package/src/client/components/layout/session-sidebar/index.tsx +87 -0
  33. package/src/client/components/mobile/SessionSidebar.tsx +78 -0
  34. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  35. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  37. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
  39. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  40. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  41. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  42. package/src/client/tailwind.css +6 -0
  43. package/src/server/lib/lifecycle/restart.ts +52 -14
  44. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  45. package/src/server/lib/updates/apply.ts +8 -0
  46. package/src/server/lib/updates/install-steps.ts +81 -0
  47. package/src/server/lib/updates/install.ts +19 -67
  48. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  49. package/src/server/lib/updates/installed-cli.ts +139 -0
  50. package/src/server/lib/updates/omp.ts +1 -1
  51. package/src/server/routes/folders/batch-delete.ts +68 -0
  52. package/src/server/routes/folders/index.ts +4 -0
  53. package/src/server/routes/sessions/batch-delete.test.ts +139 -0
  54. package/src/server/routes/sessions/batch-delete.ts +60 -0
  55. package/src/server/routes/sessions/delete.ts +13 -51
  56. package/src/server/routes/sessions/index.ts +5 -0
  57. package/src/server/routes/updates/apply.ts +1 -1
  58. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  59. package/src/shared/lib/workspace/batch-select.ts +86 -0
  60. package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
  61. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -3,6 +3,7 @@ import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
5
5
  import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
6
+ import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
6
7
 
7
8
  export interface MobileSessionRowProps {
8
9
  session: SessionItemData;
@@ -25,6 +26,11 @@ export interface MobileSessionRowProps {
25
26
  /** Ask omp to name this session from its transcript. Omitted for a pending
26
27
  * `new-…` chat, which has no transcript to derive a title from. */
27
28
  onRenameWithAi?: () => void;
29
+ /** Draw a checkbox and let the row's tap toggle the selection instead of
30
+ * opening the session (batch mode). */
31
+ selectable?: boolean;
32
+ selected?: boolean;
33
+ onToggleSelect?: () => void;
28
34
  }
29
35
 
30
36
  export function MobileSessionRow({
@@ -41,6 +47,9 @@ export function MobileSessionRow({
41
47
  onDelete,
42
48
  onRename,
43
49
  onRenameWithAi,
50
+ selectable = false,
51
+ selected = false,
52
+ onToggleSelect,
44
53
  }: MobileSessionRowProps) {
45
54
  const {
46
55
  isEditing,
@@ -91,14 +100,16 @@ export function MobileSessionRow({
91
100
  <div
92
101
  data-testid="session-item"
93
102
  data-session-id={String(session.id)}
94
- onContextMenu={hasActions ? menu.openAtCursor : undefined}
103
+ onContextMenu={hasActions && !selectable ? menu.openAtCursor : undefined}
95
104
  className={`w-full rounded-lg flex items-center transition-colors ${
96
- isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
105
+ selectable && selected
106
+ ? 'bg-ink/10 ring-1 ring-inset ring-ink/20 text-ink'
107
+ : isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
97
108
  }`}
98
109
  >
99
110
  <button
100
111
  type="button"
101
- onClick={onSelect}
112
+ onClick={selectable ? onToggleSelect : onSelect}
102
113
  className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
103
114
  >
104
115
  <div className="flex items-center min-w-0 pr-2">
@@ -107,7 +118,16 @@ export function MobileSessionRow({
107
118
  outranks the run spinner — the spinner says work is happening,
108
119
  the question mark says it is YOUR turn (desktop parity). An
109
120
  in-flight rename outranks both, for the same reason it does
110
- there: it answers a click that is still pending. */}
121
+ there: it answers a click that is still pending.
122
+ In batch mode the slot carries the checkbox instead: a row that
123
+ is a choice should not also report a run. */}
124
+ {selectable ? (
125
+ <SelectionCheckbox
126
+ checked={selected}
127
+ label={`Select ${session.title}`}
128
+ onChange={() => onToggleSelect?.()}
129
+ />
130
+ ) : (
111
131
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
112
132
  {renaming ? (
113
133
  <span role="status" aria-label="Generating a name…">
@@ -121,6 +141,7 @@ export function MobileSessionRow({
121
141
  <Check size={13} className="text-ink/50" />
122
142
  ) : null}
123
143
  </span>
144
+ )}
124
145
  <span className="text-xs truncate leading-snug ml-2">{title}</span>
125
146
  </div>
126
147
 
@@ -134,7 +155,7 @@ export function MobileSessionRow({
134
155
  column so the trailing glyph's centre stays 19px from the row's right
135
156
  edge whether the last element is this trigger or the chevron. Without
136
157
  it the glyphs step in and out from row to row. */}
137
- {hasActions && (
158
+ {hasActions && !selectable && (
138
159
  <button
139
160
  type="button"
140
161
  onClick={menu.openBelow}
@@ -151,7 +172,7 @@ export function MobileSessionRow({
151
172
  {/* Last element, so its glyph sits in the folder header's chevron column.
152
173
  `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
153
174
  touch-sized tap target. */}
154
- {showChevron && (
175
+ {showChevron && !selectable && (
155
176
  <button
156
177
  type="button"
157
178
  onClick={onToggleExpand}
@@ -72,6 +72,8 @@ async function mount(overrides: Partial<MobileSessionToolbarProps> = {}) {
72
72
  onHideArchived: () => { calls.hidden += 1; },
73
73
  onSearchChange: () => {},
74
74
  onClearSearch: () => {},
75
+ onSelectWorkspaces: () => {},
76
+ selecting: false,
75
77
  ...overrides,
76
78
  }), container);
77
79
  });
@@ -1,5 +1,5 @@
1
1
  import type { RefObject } from 'preact/compat';
2
- import { Archive, ArrowUpDown, Calendar, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
2
+ import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
3
3
  import type { SessionSortOption } from '@/shared/types';
4
4
  import { SortMenu } from '@/client/components/common/sort-menu';
5
5
 
@@ -24,6 +24,10 @@ export interface MobileSessionToolbarProps {
24
24
  onHideArchived: () => void;
25
25
  onSearchChange: (value: string) => void;
26
26
  onClearSearch: () => void;
27
+ /** Enter the workspace batch-selection mode (checkbox on each workspace). */
28
+ onSelectWorkspaces: () => void;
29
+ /** True while a batch selection is active — the control reads pressed. */
30
+ selecting: boolean;
27
31
  }
28
32
 
29
33
  export function MobileSessionToolbar({
@@ -45,6 +49,8 @@ export function MobileSessionToolbar({
45
49
  onHideArchived,
46
50
  onSearchChange,
47
51
  onClearSearch,
52
+ onSelectWorkspaces,
53
+ selecting,
48
54
  }: MobileSessionToolbarProps) {
49
55
  return (
50
56
  <div className="@container p-3 border-b border-ink/10 flex flex-col space-y-2.5 flex-shrink-0 bg-canvas">
@@ -123,6 +129,25 @@ export function MobileSessionToolbar({
123
129
  <Archive size={16} strokeWidth={1.8} />
124
130
  </button>
125
131
 
132
+ {/* Batch selection over the WORKSPACES. A mode, like search, so it
133
+ sits with the view controls and reports its pressed state — and it
134
+ is the only way into the workspace batch, because a workspace has
135
+ no row of its own to long-press. */}
136
+ <button
137
+ type="button"
138
+ onClick={onSelectWorkspaces}
139
+ aria-pressed={selecting}
140
+ className={`w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs ${
141
+ selecting
142
+ ? 'bg-ink/10 text-ink border-ink/30'
143
+ : 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink'
144
+ }`}
145
+ title={selecting ? 'Cancel selection' : 'Select workspaces'}
146
+ aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
147
+ >
148
+ <ListChecks size={16} strokeWidth={1.8} />
149
+ </button>
150
+
126
151
  {/* Refresh sessions button — same action the desktop toolbar's
127
152
  RefreshCw performs: refetch the session list from the server.
128
153
  The icon spins for the duration of that fetch, so the click has a
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The state machine behind a batch delete, shared by both sidebars.
8
+ *
9
+ * It exists so the two layouts cannot drift on the three follow-ups that are
10
+ * load-bearing, and which are easy to get subtly wrong once per sidebar:
11
+ *
12
+ * - a removed session's client cache is forgotten (`forgetSession`), or every
13
+ * panel keyed to that id keeps serving the state of a chat that is gone;
14
+ * - when the removed set includes the session in the URL, `?sessionId=` (and
15
+ * `?subagent=`, which belongs to it) is dropped — otherwise the chat renders
16
+ * its "not found" state over a row the user just deleted;
17
+ * - the sidebar is refreshed ONCE per batch, from the server's own republish.
18
+ *
19
+ * A refusal does not abort the batch: the server already deleted everything it
20
+ * could, and the dialog lists what survived with its reason.
21
+ */
22
+
23
+ import { useCallback, useState } from 'preact/hooks';
24
+ import { useSearchParams } from '@/client/lib/router/search-params';
25
+ import { forgetSession } from '@/shared/lib/workspace/session-state/store';
26
+ import {
27
+ deleteSessionsBatch,
28
+ deleteWorkspacesBatch,
29
+ type BatchDeleteOutcome,
30
+ } from '@/client/hooks/workspace/batch-delete';
31
+ import type { BatchMode } from '@/shared/lib/workspace/batch-select';
32
+
33
+ export interface BatchDeleteRun {
34
+ /** The ids awaiting confirmation; empty when the dialog is closed. */
35
+ pending: string[];
36
+ isDeleting: boolean;
37
+ /** The settled result, or null while the request runs. */
38
+ outcome: BatchDeleteOutcome | null;
39
+ /** Open the confirmation for these ids. */
40
+ request: (ids: readonly string[]) => void;
41
+ /** Close it without deleting. */
42
+ cancel: () => void;
43
+ /** Issue the delete. Resolves with the settled outcome (null when nothing ran). */
44
+ confirm: (mode: BatchMode) => Promise<BatchDeleteOutcome | null>;
45
+ }
46
+
47
+ export function useBatchDeleteRun(refresh: () => void): BatchDeleteRun {
48
+ const [pending, setPending] = useState<string[]>([]);
49
+ const [isDeleting, setIsDeleting] = useState(false);
50
+ const [outcome, setOutcome] = useState<BatchDeleteOutcome | null>(null);
51
+ const [searchParams, setSearchParams] = useSearchParams();
52
+
53
+ const request = useCallback((ids: readonly string[]) => {
54
+ setOutcome(null);
55
+ setPending([...ids]);
56
+ }, []);
57
+
58
+ const cancel = useCallback(() => {
59
+ setOutcome(null);
60
+ setPending([]);
61
+ }, []);
62
+
63
+ const confirm = useCallback(async (mode: BatchMode): Promise<BatchDeleteOutcome | null> => {
64
+ if (pending.length === 0 || isDeleting) return null;
65
+ setIsDeleting(true);
66
+ setOutcome(null);
67
+ const ids = pending;
68
+ const result = mode === 'workspace'
69
+ ? await deleteWorkspacesBatch(ids)
70
+ : await deleteSessionsBatch(ids);
71
+
72
+ if (mode === 'session' && result.removed.length > 0) {
73
+ for (const id of result.removed) forgetSession(id);
74
+ const openSession = searchParams.get('sessionId');
75
+ if (openSession && result.removed.includes(openSession)) {
76
+ setSearchParams((prev) => {
77
+ const next = new URLSearchParams(prev);
78
+ next.delete('sessionId');
79
+ next.delete('subagent');
80
+ return next;
81
+ }, { replace: true });
82
+ }
83
+ }
84
+
85
+ if (result.removed.length > 0) refresh();
86
+ setOutcome(result);
87
+ setIsDeleting(false);
88
+ // The dialog STAYS open on a refusal so the reason can be read; a fully
89
+ // successful run closes itself, because the rows it named are gone.
90
+ if (result.removed.length === ids.length) setPending([]);
91
+ return result;
92
+ }, [pending, isDeleting, searchParams, setSearchParams, refresh]);
93
+
94
+ return { pending, isDeleting, outcome, request, cancel, confirm };
95
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The batch verbs, one call per noun.
8
+ *
9
+ * Both are server routes rather than a client loop: a loop of N single deletes
10
+ * would pay N round trips for one user action, and each of them republishes the
11
+ * sidebar on its own. The server answers ONE summary — what was removed, and
12
+ * per-id refusals with their own reason — which is what the confirmation dialog
13
+ * reports after it closes.
14
+ */
15
+
16
+ export interface BatchDeleteRefusal {
17
+ id: string;
18
+ error: string;
19
+ code?: string;
20
+ }
21
+
22
+ export interface BatchDeleteOutcome {
23
+ removed: string[];
24
+ refused: BatchDeleteRefusal[];
25
+ /** Set only when the request itself failed (network, 4xx, 5xx). */
26
+ error?: string;
27
+ }
28
+
29
+ const EMPTY: BatchDeleteOutcome = { removed: [], refused: [] };
30
+
31
+ async function postBatch(url: string, ids: readonly string[]): Promise<BatchDeleteOutcome> {
32
+ try {
33
+ const res = await fetch(url, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ ids }),
37
+ });
38
+ const body = (await res.json().catch(() => null)) as Partial<BatchDeleteOutcome> | null;
39
+ if (!res.ok) {
40
+ return { ...EMPTY, error: body?.error || `Could not delete (HTTP ${res.status}).` };
41
+ }
42
+ return {
43
+ removed: Array.isArray(body?.removed) ? body.removed : [],
44
+ refused: Array.isArray(body?.refused) ? body.refused : [],
45
+ };
46
+ } catch (error) {
47
+ return { ...EMPTY, error: error instanceof Error ? error.message : 'Could not reach the server.' };
48
+ }
49
+ }
50
+
51
+ /** Remove sessions, transcripts included. Pending `new-…` ids are refused by the server. */
52
+ export function deleteSessionsBatch(ids: readonly string[]): Promise<BatchDeleteOutcome> {
53
+ return postBatch('/api/sessions/batch-delete', ids);
54
+ }
55
+
56
+ /**
57
+ * Unregister workspaces. Same semantics as the single "Delete Workspace"
58
+ * action — the transcript files on disk are NOT touched, only the sidebar
59
+ * registration (and the tombstone that stops the sync re-adding it).
60
+ */
61
+ export function deleteWorkspacesBatch(ids: readonly string[]): Promise<BatchDeleteOutcome> {
62
+ return postBatch('/api/folders/batch-delete', ids);
63
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One batch selection, for both sidebars and both nouns.
8
+ *
9
+ * The mode is the state that makes the checkboxes exist at all: `null` means
10
+ * the sidebar is behaving normally, and entering a mode is the user's explicit
11
+ * act (a workspace's `⋯` menu, or the toolbar's Select control). Leaving it —
12
+ * by Cancel, by Escape, or by finishing a delete — clears the selection, so a
13
+ * mode never comes back pre-filled with rows the user no longer remembers
14
+ * picking.
15
+ *
16
+ * The selection is pruned against what the sidebar can currently offer, but
17
+ * pruning is deliberately NOT done in an effect: a row that disappears for one
18
+ * render (a folder collapsing under a filter, a push mid-refresh) must not
19
+ * silently drop a choice. Callers pass the ids they are about to delete, and
20
+ * the delete path resolves them against the live list.
21
+ */
22
+
23
+ import { useCallback, useMemo, useState } from 'preact/hooks';
24
+ import {
25
+ selectionSummary,
26
+ toggleSelection,
27
+ type BatchMode,
28
+ type SelectionSummary,
29
+ } from '@/shared/lib/workspace/batch-select';
30
+
31
+ export interface BatchSelectionHandle {
32
+ /** The active mode, or null when the sidebar is not selecting. */
33
+ mode: BatchMode | null;
34
+ /**
35
+ * The workspace a SESSION selection is scoped to. A session batch is started
36
+ * from one workspace's `⋯` menu, so the checkboxes belong to that workspace's
37
+ * rows only — the other folders stay ordinary lists, which is also what makes
38
+ * "select all" unambiguous. Null in workspace mode.
39
+ */
40
+ scopeFolderId: number | null;
41
+ /** The ids selected in the active mode, in click order. */
42
+ selected: string[];
43
+ /** Enter a mode, replacing any previous selection. */
44
+ begin: (mode: BatchMode, scopeFolderId?: number) => void;
45
+ /** Leave the mode and forget the selection. */
46
+ end: () => void;
47
+ /** Add or remove one id. */
48
+ toggle: (id: string) => void;
49
+ /** Add every id in `ids` that is not already selected. */
50
+ selectAllOf: (ids: readonly string[]) => void;
51
+ /** Drop every id in `ids` from the selection. */
52
+ unselectAllOf: (ids: readonly string[]) => void;
53
+ /** Tri-state summary of the selection against a set of available ids. */
54
+ summaryOf: (available: readonly string[]) => SelectionSummary;
55
+ }
56
+
57
+ export function useBatchSelection(): BatchSelectionHandle {
58
+ const [mode, setMode] = useState<BatchMode | null>(null);
59
+ const [scopeFolderId, setScopeFolderId] = useState<number | null>(null);
60
+ const [selected, setSelected] = useState<string[]>([]);
61
+
62
+ const begin = useCallback((next: BatchMode, folderId?: number) => {
63
+ setMode(next);
64
+ setScopeFolderId(next === 'session' ? folderId ?? null : null);
65
+ setSelected([]);
66
+ }, []);
67
+
68
+ const end = useCallback(() => {
69
+ setMode(null);
70
+ setScopeFolderId(null);
71
+ setSelected([]);
72
+ }, []);
73
+
74
+ const toggle = useCallback((id: string) => {
75
+ setSelected((prev) => toggleSelection(prev, id));
76
+ }, []);
77
+
78
+ const selectAllOf = useCallback((ids: readonly string[]) => {
79
+ setSelected((prev) => [...new Set([...prev, ...ids])]);
80
+ }, []);
81
+
82
+ const unselectAllOf = useCallback((ids: readonly string[]) => {
83
+ setSelected((prev) => {
84
+ const drop = new Set(ids);
85
+ return prev.filter((id) => !drop.has(id));
86
+ });
87
+ }, []);
88
+
89
+ const summaryOf = useCallback(
90
+ (available: readonly string[]) => selectionSummary(selected, available),
91
+ [selected],
92
+ );
93
+
94
+ return useMemo(
95
+ () => ({ mode, scopeFolderId, selected, begin, end, toggle, selectAllOf, unselectAllOf, summaryOf }),
96
+ [mode, scopeFolderId, selected, begin, end, toggle, selectAllOf, unselectAllOf, summaryOf],
97
+ );
98
+ }
@@ -43,6 +43,12 @@
43
43
  of the K's upper arm (`public/icon.svg`) — and it is the only chroma
44
44
  outside the error red. */
45
45
  --color-brand: #f26522;
46
+ /* The icon's tile and the ink on it, also not theme tokens: the wordmark's K
47
+ is the app icon (`public/icon.svg`), so its green plate and cream strokes
48
+ must be identical in every palette — including the dark ones, where a
49
+ themed surface would turn the plate into a hole. */
50
+ --color-brand-tile: #0b5d3b;
51
+ --color-brand-cream: #f7f4ea;
46
52
  }
47
53
 
48
54
  @layer base {
@@ -20,9 +20,6 @@
20
20
  * launcher is what brings the new files up.
21
21
  */
22
22
 
23
- import { existsSync } from 'node:fs';
24
- import { join as joinPath } from 'node:path';
25
-
26
23
  import { resolveBunBin } from '@/server/lib/lifecycle/bun';
27
24
  import { packageDir } from '@/server/lib/fs/package-root';
28
25
  import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
@@ -30,6 +27,7 @@ import { isCliManaged, resolveLaunchMode, unmanagedReason } from '@/server/lib/l
30
27
  import { capitalizeFirstLetter } from '@/shared/lib/chat/capitalize';
31
28
  import { loadAuthConfig } from '@/server/lib/auth/guard';
32
29
  import { PASSWORD_HANDOVER_ENV, PASSWORD_HANDOVER_STDIN, serializeHandover } from '@/shared/lib/auth/handover';
30
+ import { copyAt, type InstalledCopy } from '@/server/lib/updates/installed-cli';
33
31
  import type { UpdateApplyResult } from '@/shared/types/updates';
34
32
 
35
33
  /**
@@ -87,11 +85,46 @@ function ownInstance(): { port: number; launchMode: string } | null {
87
85
  return { port, launchMode: resolveLaunchMode() };
88
86
  }
89
87
 
90
- /** `kabmo restart --port <port>` for the install this server runs from. */
91
- function restartCommand(port: number): string[] | null {
92
- const cli = joinPath(PKG_ROOT, 'src', 'cli', 'kabmo.js');
93
- if (!existsSync(cli)) return null;
94
- return [resolveBunBin(), cli, 'restart', '--port', String(port)];
88
+ /**
89
+ * The marker the TARGET copy reads its credential through.
90
+ *
91
+ * The rename renamed the variable too (`OMPCHAMBER_PASSWORD_HANDOVER` →
92
+ * `KABMO_PASSWORD_HANDOVER`), so a restart that set only one spelling would
93
+ * hand a payload to a CLI that never looks for it — and the replacement would
94
+ * come back with authentication silently disabled. Keyed by package name
95
+ * because that is the only fact about the target this process can read.
96
+ */
97
+ const HANDOVER_ENV_BY_PACKAGE: Record<string, string> = {
98
+ 'ompchamber': 'OMPCHAMBER_PASSWORD_HANDOVER',
99
+ '@kabmo-labs/kabmo': PASSWORD_HANDOVER_ENV,
100
+ };
101
+
102
+ export interface RestartInvocation {
103
+ /** argv for the detached helper: `<bun> <cli> restart --port <port>`. */
104
+ cmd: string[];
105
+ /** The variable that CLI reads its credential marker from. */
106
+ handoverEnv: string;
107
+ /** Package root the helper runs in. */
108
+ root: string;
109
+ }
110
+
111
+ /**
112
+ * How to restart the copy rooted at `root`.
113
+ *
114
+ * The CLI is that copy's OWN registered bin, resolved from its `package.json`,
115
+ * because the file name is not a constant across a rename: a renamed package
116
+ * ships only the bin it declares, so a hard-coded `kabmo.js` finds nothing in
117
+ * a copy that names it something else. Null when the root holds no runnable
118
+ * copy.
119
+ */
120
+ export function restartInvocation(port: number, root: string): RestartInvocation | null {
121
+ const copy = copyAt(root);
122
+ if (!copy) return null;
123
+ return {
124
+ cmd: [resolveBunBin(), copy.cli, 'restart', '--port', String(port)],
125
+ handoverEnv: HANDOVER_ENV_BY_PACKAGE[copy.packageName] ?? PASSWORD_HANDOVER_ENV,
126
+ root: copy.root,
127
+ };
95
128
  }
96
129
 
97
130
  /**
@@ -127,7 +160,7 @@ export function withRestartPlan(result: UpdateApplyResult, plan: SelfRestartPlan
127
160
  * helper is detached so this process exiting does not take it down, and it
128
161
  * removes this port's record itself (`stopInstance`) before serving.
129
162
  */
130
- export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): SelfRestartPlan {
163
+ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update', installed: InstalledCopy | null = null): SelfRestartPlan {
131
164
  const self = ownInstance();
132
165
  if (!self) {
133
166
  const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
@@ -138,11 +171,16 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
138
171
  return { restarting: false, message: skipRestartNote(self.launchMode, purpose) };
139
172
  }
140
173
 
141
- const cmd = restartCommand(self.port);
142
- if (!cmd) {
174
+ // The copy that must come back is the one the update INSTALLED. Falling back
175
+ // to this process's own root keeps `Restart` (which installs nothing) and a
176
+ // caller that could not read the install working as before.
177
+ const root = installed?.root ?? PKG_ROOT;
178
+ const invocation = restartInvocation(self.port, root);
179
+ if (!invocation) {
143
180
  const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
144
- return { restarting: false, message: `The kabmo CLI is missing from ${PKG_ROOT} — ${nextStep}.` };
181
+ return { restarting: false, message: `The CLI is missing from ${root} — ${nextStep}.` };
145
182
  }
183
+ const { cmd, handoverEnv } = invocation;
146
184
 
147
185
  // The replacement must come up with the SAME credential this process is
148
186
  // serving under. Without it, a restart silently disables authentication —
@@ -160,7 +198,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
160
198
  try {
161
199
  const child = Bun.spawn({
162
200
  cmd,
163
- cwd: PKG_ROOT,
201
+ cwd: root,
164
202
  stdin: credential ? 'pipe' : 'ignore',
165
203
  stdout: 'inherit',
166
204
  stderr: 'inherit',
@@ -169,7 +207,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
169
207
  // CLI forwards it to the server it spawns, which is the only place it
170
208
  // is ever read.
171
209
  env: credential
172
- ? { ...Bun.env, [PASSWORD_HANDOVER_ENV]: PASSWORD_HANDOVER_STDIN }
210
+ ? { ...Bun.env, [handoverEnv]: PASSWORD_HANDOVER_STDIN }
173
211
  : Bun.env,
174
212
  });
175
213
  if (credential && child.stdin) {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The one session-removal sequence, shared by the single DELETE route and the
8
+ * batch route.
9
+ *
10
+ * The ORDER is the contract and it is enforced here, not by the callers:
11
+ * 1. refuse a pending `new-…` chat and an id that is not one path segment —
12
+ * both name something other than the session they appear to;
13
+ * 2. refuse a session whose omp child is mid-turn (a run, a live subagent, or
14
+ * a dialog the agent is blocked on) — the delete would kill work the user
15
+ * can still see, and omp would flush the file back on shutdown anyway;
16
+ * 3. destroy the child and AWAIT its exit — omp writes session state on
17
+ * shutdown and would otherwise recreate the transcript just removed;
18
+ * 4. purge side questions, then the transcript, artifacts and Kabmo rows.
19
+ *
20
+ * A batch that re-implemented any of this would drift from the single delete,
21
+ * and the drift would be silent: the route answers per-id verdicts either way.
22
+ */
23
+
24
+ import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
25
+ import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
26
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
27
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
28
+ import { isMockMode } from '@/server/mock.server';
29
+
30
+ export interface SessionRemoval {
31
+ /** True when the session was removed (or would have been, in mock mode). */
32
+ ok: boolean;
33
+ /** HTTP status the single-session route answers with; batch callers use `error`. */
34
+ status: number;
35
+ code?: 'session_pending' | 'invalid_id' | 'session_busy' | 'not_found';
36
+ error?: string;
37
+ fileRemoved?: boolean;
38
+ artifactsRemoved?: boolean;
39
+ btwTopicsRemoved?: number;
40
+ }
41
+
42
+ /**
43
+ * Remove one session, in the documented order. Never throws: every failure is
44
+ * a verdict, because a batch has to keep going after one refused id.
45
+ */
46
+ export async function removeSession(sessionId: string): Promise<SessionRemoval> {
47
+ if (sessionId.startsWith('new-')) {
48
+ return {
49
+ ok: false,
50
+ status: 400,
51
+ code: 'session_pending',
52
+ error: 'This chat has not been created yet.',
53
+ };
54
+ }
55
+ // The id names a directory under the sessions root and another under the btw
56
+ // root, so it must be exactly one path segment — see `isSinglePathSegment`
57
+ // for the `.` case that made this load-bearing.
58
+ if (!isSinglePathSegment(sessionId)) {
59
+ return { ok: false, status: 400, code: 'invalid_id', error: 'Invalid session id' };
60
+ }
61
+
62
+ try {
63
+ // A live child is the only other writer of the transcript. Busy means a
64
+ // turn, a live subagent, or a dialog omp is parked on.
65
+ const live = getRpcSession(sessionId);
66
+ if (live?.isAlive()) {
67
+ if (live.isBusy()) {
68
+ return {
69
+ ok: false,
70
+ status: 409,
71
+ code: 'session_busy',
72
+ error: 'This session is working. Stop the run before deleting it.',
73
+ };
74
+ }
75
+ await live.destroyAndWait();
76
+ }
77
+
78
+ // Side questions live outside the session file and are removed first: their
79
+ // children must be gone before the parent transcript they were snapshotted
80
+ // from disappears.
81
+ const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
82
+ const purged = await purgeSessionData(sessionId);
83
+ if (!purged.found) {
84
+ return { ok: false, status: 404, code: 'not_found', error: 'Session not found' };
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ status: 200,
90
+ fileRemoved: purged.fileRemoved,
91
+ artifactsRemoved: purged.artifactsRemoved,
92
+ btwTopicsRemoved,
93
+ };
94
+ } catch (error) {
95
+ return {
96
+ ok: false,
97
+ status: 500,
98
+ error: error instanceof Error ? error.message : String(error),
99
+ };
100
+ }
101
+ }
@@ -12,6 +12,7 @@ import { updateKabmo } from '@/server/lib/updates/install';
12
12
  import { applyOmpUpdate } from '@/server/lib/updates/omp';
13
13
  import { withUpdateSlot } from '@/server/lib/updates/single-flight';
14
14
  import { recycleStandbyProcesses } from '@/server/lib/omp/session/standby.server';
15
+ import type { InstalledCopy } from '@/server/lib/updates/installed-cli';
15
16
  import type { UpdateApplyResult, UpdateTarget } from '@/shared/types/updates';
16
17
 
17
18
  /** A stage label or a line of command output, as the update produces it. */
@@ -27,6 +28,12 @@ export interface UpdateProgressHooks {
27
28
  export interface AppliedUpdate {
28
29
  result: UpdateApplyResult;
29
30
  updated: boolean;
31
+ /**
32
+ * The copy this run left on disk, when it knows. The restart that follows
33
+ * launches THIS one — the running process's own directory is the build the
34
+ * update just replaced, which is how a rename came back as the old product.
35
+ */
36
+ installed?: InstalledCopy | null;
30
37
  }
31
38
 
32
39
  /**
@@ -74,6 +81,7 @@ export async function applyUpdate(target: UpdateTarget, hooks: UpdateProgressHoo
74
81
  output: result.output || undefined,
75
82
  },
76
83
  updated: result.updated,
84
+ installed: result.installed,
77
85
  };
78
86
  }
79
87