ompchamber 0.8.0 → 1.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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -17,6 +17,7 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
|
17
17
|
import { streamChatResponse } from '@/client/hooks/chat/stream';
|
|
18
18
|
import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
|
|
19
19
|
import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
|
|
20
|
+
import { flushDeferredPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
20
21
|
import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
|
|
21
22
|
import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
|
|
22
23
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
@@ -51,9 +52,9 @@ export interface ChatTimelineSendDeps {
|
|
|
51
52
|
/** Global access-control mode (persisted user preference), read at send time
|
|
52
53
|
* so the spawn-capable requests carry the latest value. */
|
|
53
54
|
accessModeRef: { current: ApprovalMode };
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
55
|
+
/** Picks the composer made while a turn was streaming. Pushed onto the
|
|
56
|
+
* session here, immediately before the prompt they were meant for. */
|
|
57
|
+
deferredComposerPickRef: DeferredModelStore;
|
|
57
58
|
abortControllerRef: { current: AbortController | null };
|
|
58
59
|
setInputValue: (v: string) => void;
|
|
59
60
|
setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
|
|
@@ -91,6 +92,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
91
92
|
seedSession,
|
|
92
93
|
pendingComposerModelRef,
|
|
93
94
|
pendingThinkingLevelRef,
|
|
95
|
+
deferredComposerPickRef,
|
|
94
96
|
accessModeRef,
|
|
95
97
|
abortControllerRef,
|
|
96
98
|
setInputValue,
|
|
@@ -133,10 +135,13 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
133
135
|
|
|
134
136
|
// Steer the running omp agent with a fresh prompt (interrupt-and-reply).
|
|
135
137
|
const steerOmpAgent = useCallback(async (text: string, attachments: Attachment[]) => {
|
|
138
|
+
// A steer IS a new prompt: a pick made during the interrupted turn belongs
|
|
139
|
+
// to it, so it is pushed before the interrupt-and-reply starts the turn.
|
|
140
|
+
await flushDeferredPick(ompAgent, deferredComposerPickRef);
|
|
136
141
|
const { promptText, images } = await prepareDeliverable(text, attachments);
|
|
137
142
|
const ok = await ompAgent.sendInterruptAndReply(promptText, images);
|
|
138
143
|
if (!ok) setInputValue(text);
|
|
139
|
-
}, [ompAgent, prepareDeliverable, setInputValue]);
|
|
144
|
+
}, [ompAgent, prepareDeliverable, setInputValue, deferredComposerPickRef]);
|
|
140
145
|
|
|
141
146
|
const executeSend = useCallback(async (
|
|
142
147
|
text: string,
|
|
@@ -209,6 +214,12 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
209
214
|
if (isOmpSession) {
|
|
210
215
|
aiPlaceholderIdRef.current = aiPlaceholderId;
|
|
211
216
|
optimisticUserIdRef.current = userMsgId;
|
|
217
|
+
// A model/thinking pick made while the previous turn streamed was held
|
|
218
|
+
// back so it could not re-target that answer. A queued delivery does NOT
|
|
219
|
+
// own it: that item carries its own snapshot, so the pick stays pending
|
|
220
|
+
// for the composer's own next prompt instead of being spent — and then
|
|
221
|
+
// overwritten — by a delivery it was never meant for.
|
|
222
|
+
if (!modelOverride) await flushDeferredPick(ompAgent, deferredComposerPickRef);
|
|
212
223
|
// Replay the queued snapshot first so the prompt runs on the exact
|
|
213
224
|
// model/thinking the item was queued with.
|
|
214
225
|
if (modelOverride) {
|
|
@@ -329,6 +340,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
329
340
|
scrollToBottom,
|
|
330
341
|
})
|
|
331
342
|
);
|
|
332
|
-
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
|
|
343
|
+
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef]);
|
|
333
344
|
return { steerOmpAgent, executeSend };
|
|
334
345
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Measure an element's content width and keep it current.
|
|
6
|
+
*
|
|
7
|
+
* Panel widths are stored as a share of the group's available area, so the
|
|
8
|
+
* group has to report that area before any fraction can become pixels. Returns
|
|
9
|
+
* `null` until the first measurement — callers must treat that as "unknown",
|
|
10
|
+
* not as zero, or a panel would collapse on its first render.
|
|
11
|
+
*
|
|
12
|
+
* `ResizeObserver` is not optional here: the width changes on a window resize,
|
|
13
|
+
* on a devtools dock, and on a sibling panel being toggled, and none of those
|
|
14
|
+
* are `window` events.
|
|
15
|
+
*/
|
|
16
|
+
export function useAvailableWidth(ref: RefObject<HTMLElement | null>): number | null {
|
|
17
|
+
const [available, setAvailable] = useState<number | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const element = ref.current;
|
|
21
|
+
if (!element || typeof ResizeObserver === 'undefined') return;
|
|
22
|
+
|
|
23
|
+
const observer = new ResizeObserver(() => {
|
|
24
|
+
setAvailable(element.clientWidth || null);
|
|
25
|
+
});
|
|
26
|
+
observer.observe(element);
|
|
27
|
+
setAvailable(element.clientWidth || null);
|
|
28
|
+
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, [ref]);
|
|
31
|
+
|
|
32
|
+
return available;
|
|
33
|
+
}
|
|
@@ -1,59 +1,56 @@
|
|
|
1
|
-
import { useCallback,
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import { useCallback, useMemo } from 'preact/hooks';
|
|
2
|
+
import {
|
|
3
|
+
mergePanelWidths,
|
|
4
|
+
normalizePanelWidths,
|
|
5
|
+
type PanelWidths,
|
|
6
|
+
} from '@/shared/lib/workspace/panel-widths';
|
|
4
7
|
import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
|
|
5
|
-
import {
|
|
8
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
10
|
+
/** Session-state key holding this session's own per-panel widths. */
|
|
11
|
+
const PANEL_WIDTHS_KEY = 'layout.panelWidths';
|
|
9
12
|
|
|
10
13
|
interface PanelWidthsResult {
|
|
11
14
|
widths: PanelWidths;
|
|
12
|
-
/**
|
|
13
|
-
widthsRef: RefObject<PanelWidths>;
|
|
14
|
-
/** Fold measured panel widths in and persist the merged map. */
|
|
15
|
+
/** Fold measured panel widths into this session's map. */
|
|
15
16
|
commitWidths: (patch: PanelWidths) => void;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Owns the desktop layout's per-panel width map
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Owns the desktop layout's per-panel width map.
|
|
21
|
+
*
|
|
22
|
+
* Widths are **per session**: they live in `session_ui_state` under
|
|
23
|
+
* `layout.panelWidths`, next to the rest of a session's layout state
|
|
24
|
+
* (`layout.activeRightPanel`, `layout.showRightPanel`, `layout.openedFiles`), so
|
|
25
|
+
* switching sessions brings back the layout that session was left in.
|
|
26
|
+
*
|
|
27
|
+
* `app_settings.desktopLayoutSizes` is the **seed**: a session that has never
|
|
28
|
+
* been resized reads it, which is what keeps an existing user's layout across
|
|
29
|
+
* the upgrade instead of resetting everyone to the defaults. Once a panel is
|
|
30
|
+
* dragged, the session stores its own map and the seed is no longer consulted
|
|
31
|
+
* for that session.
|
|
32
|
+
*
|
|
33
|
+
* There is deliberately no px→fraction rewrite pass here. Converting a legacy
|
|
34
|
+
* pixel value is done when a width is *read* (`resolvePanelWidth`), because a
|
|
35
|
+
* write would have to run before this session's own blob has loaded and would
|
|
36
|
+
* therefore overwrite it with the seed.
|
|
23
37
|
*/
|
|
24
38
|
export function usePanelWidths(
|
|
25
39
|
appSettings: Record<string, any>,
|
|
26
40
|
legacyView: RightPanelType,
|
|
27
41
|
): PanelWidthsResult {
|
|
28
|
-
const
|
|
29
|
-
normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
42
|
+
const seed = useMemo(
|
|
43
|
+
() => normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
44
|
+
[appSettings.desktopLayoutSizes, legacyView],
|
|
30
45
|
);
|
|
31
|
-
const
|
|
32
|
-
// Set while a debounced write is pending, which is also the flush condition.
|
|
33
|
-
const timerRef = useRef<number | undefined>(undefined);
|
|
34
|
-
|
|
35
|
-
const saveNow = useCallback(() => {
|
|
36
|
-
clearTimeout(timerRef.current);
|
|
37
|
-
timerRef.current = undefined;
|
|
38
|
-
writeSetting('desktopLayoutSizes', widthsRef.current);
|
|
39
|
-
}, []);
|
|
46
|
+
const [widths, setWidths] = useSessionState<PanelWidths>(PANEL_WIDTHS_KEY, seed);
|
|
40
47
|
|
|
41
48
|
const commitWidths = useCallback(
|
|
42
49
|
(patch: PanelWidths) => {
|
|
43
|
-
|
|
44
|
-
widthsRef.current = next;
|
|
45
|
-
setWidths(next);
|
|
46
|
-
clearTimeout(timerRef.current);
|
|
47
|
-
timerRef.current = window.setTimeout(saveNow, PERSIST_DEBOUNCE_MS);
|
|
50
|
+
setWidths((prev) => mergePanelWidths(prev, patch));
|
|
48
51
|
},
|
|
49
|
-
[
|
|
52
|
+
[setWidths],
|
|
50
53
|
);
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
// the user just dragged.
|
|
54
|
-
useEffect(() => () => {
|
|
55
|
-
if (timerRef.current !== undefined) saveNow();
|
|
56
|
-
}, [saveNow]);
|
|
57
|
-
|
|
58
|
-
return { widths, widthsRef, commitWidths };
|
|
55
|
+
return { widths, commitWidths };
|
|
59
56
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live terminal session for the right panel: the socket, its status, and the
|
|
8
|
+
* attach/resize/input verbs the xterm view drives.
|
|
9
|
+
*
|
|
10
|
+
* The terminal id is per chamber session and persisted, which is what makes the
|
|
11
|
+
* shell survive a page reload: the panel reattaches to the id it already owns
|
|
12
|
+
* and the server replays its scrollback. `restart` mints a new id, so a shell
|
|
13
|
+
* that was exited (or wedged) is left behind rather than resurrected.
|
|
14
|
+
*
|
|
15
|
+
* The view owns the grid — it is the only thing that knows the container's
|
|
16
|
+
* size — so attach and resize flow view → hook, while output flows hook → view
|
|
17
|
+
* through the imperative handle the panel wires up.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
21
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
22
|
+
import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
23
|
+
import { getSessionValue } from '@/shared/lib/workspace/session-state/store';
|
|
24
|
+
import { connectTerminal, type TerminalSocket } from '@/client/hooks/workspace/terminal/socket';
|
|
25
|
+
import { newTerminalId, type TerminalReadyFrame } from '@/shared/lib/workspace/terminal/protocol';
|
|
26
|
+
|
|
27
|
+
export type TerminalStatus = 'connecting' | 'running' | 'exited' | 'error';
|
|
28
|
+
|
|
29
|
+
export interface TerminalSystemInfo {
|
|
30
|
+
gitBranch: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseTerminalOptions {
|
|
34
|
+
root?: string;
|
|
35
|
+
repo?: string;
|
|
36
|
+
theme?: 'light' | 'dark';
|
|
37
|
+
onOutput?: (bytes: Uint8Array) => void;
|
|
38
|
+
onReplay?: (bytes: Uint8Array, frame: TerminalReadyFrame) => void;
|
|
39
|
+
/** Fired after the shell is replaced, so the view can drop its buffer. */
|
|
40
|
+
onRestart?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UseTerminalResult {
|
|
44
|
+
status: TerminalStatus;
|
|
45
|
+
/** Working directory the shell runs in, once known. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
shell: string;
|
|
48
|
+
/** Exit code of a finished shell, or null while it runs. */
|
|
49
|
+
exitCode: number | null;
|
|
50
|
+
/** Set when the socket reported an error frame. */
|
|
51
|
+
error: string | null;
|
|
52
|
+
systemInfo: TerminalSystemInfo;
|
|
53
|
+
attach: (cols: number, rows: number) => void;
|
|
54
|
+
resize: (cols: number, rows: number) => void;
|
|
55
|
+
input: (data: Uint8Array) => void;
|
|
56
|
+
/** Replace the shell with a fresh one. */
|
|
57
|
+
restart: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useTerminal(options: UseTerminalOptions = {}): UseTerminalResult {
|
|
61
|
+
const { sessionId, ready } = useSessionStateContext();
|
|
62
|
+
const [terminalId, setTerminalId] = useSessionState<string>('terminal.id', '');
|
|
63
|
+
const [status, setStatus] = useState<TerminalStatus>('connecting');
|
|
64
|
+
const [cwd, setCwd] = useState('');
|
|
65
|
+
const [shell, setShell] = useState('');
|
|
66
|
+
const [exitCode, setExitCode] = useState<number | null>(null);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
const [systemInfo, setSystemInfo] = useState<TerminalSystemInfo>({ gitBranch: 'main' });
|
|
69
|
+
|
|
70
|
+
const socketRef = useRef<TerminalSocket | null>(null);
|
|
71
|
+
// The grid the view reported, replayed on attach so a reconnect lands on the
|
|
72
|
+
// size the panel actually has instead of the default.
|
|
73
|
+
const sizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
|
74
|
+
const outputRef = useRef(options.onOutput);
|
|
75
|
+
outputRef.current = options.onOutput;
|
|
76
|
+
const replayRef = useRef(options.onReplay);
|
|
77
|
+
replayRef.current = options.onReplay;
|
|
78
|
+
const restartRef = useRef(options.onRestart);
|
|
79
|
+
restartRef.current = options.onRestart;
|
|
80
|
+
const rootRef = useRef(options.root);
|
|
81
|
+
rootRef.current = options.root;
|
|
82
|
+
const repoRef = useRef(options.repo);
|
|
83
|
+
repoRef.current = options.repo;
|
|
84
|
+
const themeRef = useRef(options.theme);
|
|
85
|
+
themeRef.current = options.theme;
|
|
86
|
+
const setIdRef = useRef(setTerminalId);
|
|
87
|
+
setIdRef.current = setTerminalId;
|
|
88
|
+
/** Last `root\0repo` the shell was launched for; null until the first. */
|
|
89
|
+
const scopeRef = useRef<string | null>(null);
|
|
90
|
+
|
|
91
|
+
// One id per chamber session. The check reads the store, not the hook's local
|
|
92
|
+
// value: on the render where the blob first becomes ready, `useSessionState`
|
|
93
|
+
// still reports the fallback (its restore effect has not run yet), so
|
|
94
|
+
// deciding from the local value would mint a second id and overwrite the
|
|
95
|
+
// stored one — orphaning the live shell and its scrollback.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!ready) return;
|
|
98
|
+
if (getSessionValue<string>(sessionId, 'terminal.id')) return;
|
|
99
|
+
setIdRef.current(newTerminalId());
|
|
100
|
+
}, [ready, sessionId]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!ready || !terminalId) return;
|
|
104
|
+
|
|
105
|
+
setStatus('connecting');
|
|
106
|
+
setError(null);
|
|
107
|
+
setExitCode(null);
|
|
108
|
+
|
|
109
|
+
const socket = connectTerminal(terminalId, {
|
|
110
|
+
onReady(frame) {
|
|
111
|
+
setStatus('running');
|
|
112
|
+
setCwd(frame.cwd);
|
|
113
|
+
setShell(frame.shell);
|
|
114
|
+
setExitCode(null);
|
|
115
|
+
},
|
|
116
|
+
onReplay(bytes, frame) {
|
|
117
|
+
replayRef.current?.(bytes, frame);
|
|
118
|
+
},
|
|
119
|
+
onOutput(bytes) {
|
|
120
|
+
outputRef.current?.(bytes);
|
|
121
|
+
},
|
|
122
|
+
onExit(code) {
|
|
123
|
+
setStatus('exited');
|
|
124
|
+
setExitCode(code);
|
|
125
|
+
},
|
|
126
|
+
onError(code, message) {
|
|
127
|
+
setStatus('error');
|
|
128
|
+
setError(`${code}: ${message}`);
|
|
129
|
+
},
|
|
130
|
+
onConnectionChange(connected) {
|
|
131
|
+
if (!connected) setStatus('connecting');
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
socketRef.current = socket;
|
|
135
|
+
// The socket re-dials the same id after a drop; the panel's size is the one
|
|
136
|
+
// to come back at. On a restart the view is already mounted and will not
|
|
137
|
+
// report its grid again, so the remembered size is what makes the new shell
|
|
138
|
+
// spawn at the right dimensions.
|
|
139
|
+
const size = sizeRef.current;
|
|
140
|
+
if (size) socket.attach(size.cols, size.rows, rootRef.current, repoRef.current, themeRef.current);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
socket.release();
|
|
144
|
+
socketRef.current = null;
|
|
145
|
+
};
|
|
146
|
+
}, [ready, terminalId]);
|
|
147
|
+
|
|
148
|
+
const restart = useCallback(() => {
|
|
149
|
+
// Kill the old shell before minting a new id: leaving it to the idle
|
|
150
|
+
// reaper would keep a dead terminal attached to this session for 30 min.
|
|
151
|
+
socketRef.current?.close();
|
|
152
|
+
restartRef.current?.();
|
|
153
|
+
setIdRef.current(newTerminalId());
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The repo picker in the header changes the shell's scope, and a PTY's cwd is
|
|
158
|
+
* fixed at spawn — so a scope change replaces the shell instead of trying to
|
|
159
|
+
* `cd` it (which would leave the user's own shell state — exports, jobs,
|
|
160
|
+
* history — describing the old tree).
|
|
161
|
+
*
|
|
162
|
+
* The first scope seen is adopted without restarting: `root` arrives
|
|
163
|
+
* asynchronously (the active project resolves with the sidebar data), and
|
|
164
|
+
* restarting on that first transition would kill the shell the view is still
|
|
165
|
+
* attaching.
|
|
166
|
+
*/
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!ready) return;
|
|
169
|
+
const root = options.root;
|
|
170
|
+
if (!root) return;
|
|
171
|
+
const scope = `${root}\u0000${options.repo ?? '.'}`;
|
|
172
|
+
if (scopeRef.current === null) {
|
|
173
|
+
scopeRef.current = scope;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (scopeRef.current === scope) return;
|
|
177
|
+
scopeRef.current = scope;
|
|
178
|
+
restart();
|
|
179
|
+
}, [ready, options.root, options.repo, restart]);
|
|
180
|
+
|
|
181
|
+
// Launch context for the header (git branch), scoped like the shell itself.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const root = options.root;
|
|
184
|
+
const repo = options.repo;
|
|
185
|
+
if (!root) return;
|
|
186
|
+
const params = new URLSearchParams({ root });
|
|
187
|
+
if (repo && repo !== '.') params.set('repo', repo);
|
|
188
|
+
let cancelled = false;
|
|
189
|
+
fetch(`/api/terminal/run?${params.toString()}`)
|
|
190
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
191
|
+
.then((data: { gitBranch?: string } | null) => {
|
|
192
|
+
if (!cancelled && data?.gitBranch) setSystemInfo({ gitBranch: data.gitBranch });
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
// The header simply keeps its previous branch label.
|
|
196
|
+
});
|
|
197
|
+
return () => {
|
|
198
|
+
cancelled = true;
|
|
199
|
+
};
|
|
200
|
+
}, [options.root, options.repo]);
|
|
201
|
+
|
|
202
|
+
const attach = useCallback((cols: number, rows: number) => {
|
|
203
|
+
sizeRef.current = { cols, rows };
|
|
204
|
+
socketRef.current?.attach(cols, rows, rootRef.current, repoRef.current, themeRef.current);
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const resize = useCallback((cols: number, rows: number) => {
|
|
208
|
+
sizeRef.current = { cols, rows };
|
|
209
|
+
socketRef.current?.resize(cols, rows);
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
const input = useCallback((data: Uint8Array) => {
|
|
213
|
+
socketRef.current?.input(data);
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
return { status, cwd, shell, exitCode, error, systemInfo, attach, resize, input, restart };
|
|
217
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client transport for one terminal: dial, attach, and stay attached.
|
|
8
|
+
*
|
|
9
|
+
* One socket carries both directions — JSON control frames out, raw PTY bytes
|
|
10
|
+
* both ways. Three behaviours are deliberate:
|
|
11
|
+
*
|
|
12
|
+
* - **Binary in, binary out.** `binaryType = 'arraybuffer'` so output arrives as
|
|
13
|
+
* bytes and is handed straight to `term.write(Uint8Array)`; the client never
|
|
14
|
+
* decodes UTF-8 itself, which is what keeps a character split across two PTY
|
|
15
|
+
* reads intact.
|
|
16
|
+
* - **`onBinary` carries the same bytes as `onData`.** xterm reports non-UTF-8
|
|
17
|
+
* payloads (some mouse reports, paste of raw bytes) on `onBinary`; forwarding
|
|
18
|
+
* only `onData` would silently drop them.
|
|
19
|
+
* - **Reconnect, not re-create.** A dropped socket re-dials the same terminal
|
|
20
|
+
* id, so the shell and its scrollback survive a network blip or a page reload
|
|
21
|
+
* instead of a second shell appearing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
decodeServerFrame,
|
|
26
|
+
encodeClientFrame,
|
|
27
|
+
terminalSocketUrl,
|
|
28
|
+
type TerminalAttachFrame,
|
|
29
|
+
type TerminalClientFrame,
|
|
30
|
+
type TerminalReadyFrame,
|
|
31
|
+
type TerminalServerFrame,
|
|
32
|
+
} from '@/shared/lib/workspace/terminal/protocol';
|
|
33
|
+
|
|
34
|
+
const RECONNECT_BASE_MS = 500;
|
|
35
|
+
const RECONNECT_MAX_MS = 8000;
|
|
36
|
+
/** Consecutive re-dials that never opened before the stream is given up on. */
|
|
37
|
+
const MAX_FAILED_DIALS = 4;
|
|
38
|
+
|
|
39
|
+
export interface TerminalSocketHandlers {
|
|
40
|
+
/** The attach was accepted; `replayBytes` of scrollback follow. */
|
|
41
|
+
onReady: (frame: TerminalReadyFrame) => void;
|
|
42
|
+
/**
|
|
43
|
+
* The retained scrollback, once. The view must render this at the reported
|
|
44
|
+
* `replayCols`/`replayRows` before fitting its own viewport.
|
|
45
|
+
*/
|
|
46
|
+
onReplay: (bytes: Uint8Array, frame: TerminalReadyFrame) => void;
|
|
47
|
+
/** Live PTY output, after the replay. */
|
|
48
|
+
onOutput: (bytes: Uint8Array) => void;
|
|
49
|
+
onExit: (exitCode: number | null, signal: string | null) => void;
|
|
50
|
+
onError: (code: string, message: string) => void;
|
|
51
|
+
/** Connection state for the panel's status badge. */
|
|
52
|
+
onConnectionChange: (connected: boolean) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TerminalSocket {
|
|
56
|
+
/** Send one attach frame; ignored once a terminal is attached. */
|
|
57
|
+
attach: (cols: number, rows: number, root?: string, repo?: string, theme?: 'light' | 'dark') => void;
|
|
58
|
+
resize: (cols: number, rows: number) => void;
|
|
59
|
+
/** Raw keystrokes or paste; never JSON-encoded. */
|
|
60
|
+
input: (data: Uint8Array) => void;
|
|
61
|
+
/** Kill the shell server-side. */
|
|
62
|
+
close: () => void;
|
|
63
|
+
/** Release the socket; the shell keeps running for the next attach. */
|
|
64
|
+
release: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function connectTerminal(terminalId: string, handlers: TerminalSocketHandlers): TerminalSocket {
|
|
68
|
+
let socket: WebSocket | null = null;
|
|
69
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
let failures = 0;
|
|
71
|
+
let established = false;
|
|
72
|
+
let released = false;
|
|
73
|
+
let attached = false;
|
|
74
|
+
let lastAttach: TerminalAttachFrame | null = null;
|
|
75
|
+
/** Scrollback accumulated so far, plus how many bytes are still expected. */
|
|
76
|
+
let replayParts: Uint8Array[] = [];
|
|
77
|
+
let replayRemaining = 0;
|
|
78
|
+
let replayFrame: TerminalReadyFrame | null = null;
|
|
79
|
+
|
|
80
|
+
const dial = () => {
|
|
81
|
+
const ws = new WebSocket(terminalSocketUrl(terminalId));
|
|
82
|
+
ws.binaryType = 'arraybuffer';
|
|
83
|
+
socket = ws;
|
|
84
|
+
|
|
85
|
+
ws.onopen = () => {
|
|
86
|
+
if (released) return;
|
|
87
|
+
failures = 0;
|
|
88
|
+
established = true;
|
|
89
|
+
handlers.onConnectionChange(true);
|
|
90
|
+
// Re-attaching replays the retained scrollback, so a reconnected panel
|
|
91
|
+
// converges on the shell's real state instead of showing a gap.
|
|
92
|
+
if (lastAttach) ws.send(encodeClientFrame(lastAttach));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
ws.onmessage = (event) => {
|
|
96
|
+
if (released) return;
|
|
97
|
+
if (event.data instanceof ArrayBuffer) {
|
|
98
|
+
handleOutput(new Uint8Array(event.data));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const frame = decodeServerFrame(typeof event.data === 'string' ? event.data : '');
|
|
102
|
+
if (frame) applyFrame(frame);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
ws.onclose = () => {
|
|
106
|
+
socket = null;
|
|
107
|
+
if (released) return;
|
|
108
|
+
handlers.onConnectionChange(false);
|
|
109
|
+
attached = false;
|
|
110
|
+
replayParts = [];
|
|
111
|
+
replayRemaining = 0;
|
|
112
|
+
replayFrame = null;
|
|
113
|
+
if (!established) return;
|
|
114
|
+
failures += 1;
|
|
115
|
+
if (failures > MAX_FAILED_DIALS) return;
|
|
116
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** (failures - 1), RECONNECT_MAX_MS);
|
|
117
|
+
retryTimer = setTimeout(() => {
|
|
118
|
+
retryTimer = null;
|
|
119
|
+
if (!released) dial();
|
|
120
|
+
}, delay);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
ws.onerror = () => {
|
|
124
|
+
// `onclose` follows and owns the retry decision.
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const applyFrame = (frame: TerminalServerFrame) => {
|
|
129
|
+
if (frame.t === 'ready') {
|
|
130
|
+
attached = true;
|
|
131
|
+
replayFrame = frame;
|
|
132
|
+
replayRemaining = frame.replayBytes;
|
|
133
|
+
replayParts = [];
|
|
134
|
+
handlers.onReady(frame);
|
|
135
|
+
// An empty scrollback never produces a binary frame, so complete it here.
|
|
136
|
+
if (replayRemaining === 0) finishReplay();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (frame.t === 'exit') {
|
|
140
|
+
handlers.onExit(frame.exitCode, frame.signal);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
handlers.onError(frame.code, frame.message);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleOutput = (bytes: Uint8Array) => {
|
|
147
|
+
if (replayRemaining <= 0) {
|
|
148
|
+
handlers.onOutput(bytes);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// The server sends the scrollback as one frame, so this normally completes
|
|
152
|
+
// in a single step; the accounting is what keeps a fragmented delivery
|
|
153
|
+
// from being mistaken for live output.
|
|
154
|
+
replayParts.push(bytes);
|
|
155
|
+
replayRemaining -= bytes.length;
|
|
156
|
+
if (replayRemaining > 0) return;
|
|
157
|
+
finishReplay();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const finishReplay = () => {
|
|
161
|
+
const frame = replayFrame;
|
|
162
|
+
const parts = replayParts;
|
|
163
|
+
replayFrame = null;
|
|
164
|
+
replayParts = [];
|
|
165
|
+
replayRemaining = 0;
|
|
166
|
+
if (!frame) return;
|
|
167
|
+
handlers.onReplay(parts.length === 1 ? parts[0] : concat(parts), frame);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
dial();
|
|
171
|
+
|
|
172
|
+
const send = (frame: TerminalClientFrame) => {
|
|
173
|
+
const ws = socket;
|
|
174
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
175
|
+
ws.send(encodeClientFrame(frame));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
attach(cols, rows, root, repo, theme) {
|
|
180
|
+
if (attached) return;
|
|
181
|
+
const frame: TerminalAttachFrame = { t: 'attach', cols, rows };
|
|
182
|
+
if (root) frame.root = root;
|
|
183
|
+
if (repo) frame.repo = repo;
|
|
184
|
+
if (theme) frame.theme = theme;
|
|
185
|
+
lastAttach = frame;
|
|
186
|
+
send(frame);
|
|
187
|
+
},
|
|
188
|
+
resize(cols, rows) {
|
|
189
|
+
send({ t: 'resize', cols, rows });
|
|
190
|
+
},
|
|
191
|
+
input(data) {
|
|
192
|
+
const ws = socket;
|
|
193
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
194
|
+
ws.send(data);
|
|
195
|
+
},
|
|
196
|
+
close() {
|
|
197
|
+
send({ t: 'close' });
|
|
198
|
+
},
|
|
199
|
+
release() {
|
|
200
|
+
released = true;
|
|
201
|
+
if (retryTimer !== null) {
|
|
202
|
+
clearTimeout(retryTimer);
|
|
203
|
+
retryTimer = null;
|
|
204
|
+
}
|
|
205
|
+
const ws = socket;
|
|
206
|
+
socket = null;
|
|
207
|
+
if (!ws) return;
|
|
208
|
+
ws.onopen = null;
|
|
209
|
+
ws.onmessage = null;
|
|
210
|
+
ws.onclose = null;
|
|
211
|
+
ws.onerror = null;
|
|
212
|
+
ws.close();
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
218
|
+
let size = 0;
|
|
219
|
+
for (const part of parts) size += part.length;
|
|
220
|
+
const out = new Uint8Array(size);
|
|
221
|
+
let offset = 0;
|
|
222
|
+
for (const part of parts) {
|
|
223
|
+
out.set(part, offset);
|
|
224
|
+
offset += part.length;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
package/src/server/db.server.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
|
10
10
|
import { pathExists, projectPathKey } from '@/server/lib/omp/core/paths';
|
|
11
11
|
|
|
12
12
|
let dbPromise: Promise<DbClient> | null = null;
|
|
13
|
+
/** Resolved handle cached by getDb(); promise callbacks never run sync, so
|
|
14
|
+
* hot paths unwrap through `getDbSync` instead of awaiting. */
|
|
15
|
+
let dbResolved: DbClient | null = null;
|
|
13
16
|
|
|
14
17
|
/** SYNC_WORKSPACE env flag (default true when unset). When enabled in real
|
|
15
18
|
* mode, workspace folders are auto-created from discovered omp projects. */
|
|
@@ -123,6 +126,10 @@ export async function getDb(): Promise<DbClient> {
|
|
|
123
126
|
|
|
124
127
|
const { migrateWorkspaceFolderColumns } = await import('@/shared/lib/workspace/schema-migrations');
|
|
125
128
|
await migrateWorkspaceFolderColumns(db);
|
|
129
|
+
// Installs older `queued_messages` schemas carry an invalid FK (see the
|
|
130
|
+
// migration module); rebuild once so real-session queue inserts work.
|
|
131
|
+
const { migrateQueueTableFk } = await import('@/server/lib/queue/schema-migration.server');
|
|
132
|
+
await migrateQueueTableFk(db);
|
|
126
133
|
|
|
127
134
|
await db.exec(`
|
|
128
135
|
CREATE TABLE IF NOT EXISTS files (
|
|
@@ -292,12 +299,24 @@ export async function getDb(): Promise<DbClient> {
|
|
|
292
299
|
const { migrateLegacyProjectSettings } = await import('@/shared/lib/workspace/project-settings-migration');
|
|
293
300
|
await migrateLegacyProjectSettings(db);
|
|
294
301
|
|
|
302
|
+
dbResolved = db;
|
|
295
303
|
return db;
|
|
296
304
|
})();
|
|
297
305
|
|
|
298
306
|
return dbPromise;
|
|
299
307
|
}
|
|
300
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Synchronous handle for hot paths needing raw sync transactions
|
|
311
|
+
* (`bun:sqlite` is sync, so BEGIN…COMMIT cannot interleave with another
|
|
312
|
+
* request). The server initializes the db at startup; anything earlier must
|
|
313
|
+
* use `getDb()`.
|
|
314
|
+
*/
|
|
315
|
+
export function getDbSync(): DbClient {
|
|
316
|
+
if (!dbResolved) throw new Error('Database not initialized — call getDb() first');
|
|
317
|
+
return dbResolved;
|
|
318
|
+
}
|
|
319
|
+
|
|
301
320
|
/**
|
|
302
321
|
* Additive-only folder ↔ omp project sync: creates a workspace folder for
|
|
303
322
|
* every project discovered from ~/.omp/agent (folder name = lowercase
|