ompchamber 0.8.0 → 1.0.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 (79) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/{index.5c0447cefe.css → index.e880517fcd.css} +1 -1
  3. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  4. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  5. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  6. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  7. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  8. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  9. package/dist/client/static/js/{index.dc5f5fabe8.js → index.0de15c5e17.js} +10 -10
  10. package/package.json +2 -1
  11. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  12. package/src/client/components/layout/session-sidebar/SessionItem.tsx +1 -1
  13. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  15. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  16. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  17. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
  18. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  19. package/src/client/components/workspace/chat-timeline/index.tsx +4 -2
  20. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  21. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  22. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  23. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  24. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  25. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  26. package/src/client/data/theme/terminal.ts +27 -29
  27. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  28. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  29. package/src/client/hooks/chat/timeline/actions.ts +23 -25
  30. package/src/client/hooks/chat/timeline/index.ts +12 -20
  31. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  32. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  33. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  34. package/src/server/db.server.ts +19 -0
  35. package/src/server/lib/fs/git-sync.ts +191 -0
  36. package/src/server/lib/omp/rpc/manager.ts +58 -3
  37. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  38. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  39. package/src/server/lib/queue/delivery.server.ts +114 -0
  40. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  41. package/src/server/lib/queue/store.server.ts +185 -0
  42. package/src/server/lib/terminal/foreground.ts +73 -0
  43. package/src/server/lib/terminal/history.test.ts +118 -0
  44. package/src/server/lib/terminal/history.ts +205 -0
  45. package/src/server/lib/terminal/runtime.server.ts +338 -0
  46. package/src/server/lib/terminal/session.server.ts +215 -0
  47. package/src/server/lib/terminal/shell.test.ts +97 -0
  48. package/src/server/lib/terminal/shell.ts +103 -0
  49. package/src/server/routes/fs/git.ts +22 -12
  50. package/src/server/routes/index.ts +2 -1
  51. package/src/server/routes/sessions/index.ts +11 -3
  52. package/src/server/routes/sessions/queue.ts +121 -0
  53. package/src/server/routes/sessions/session.ts +1 -132
  54. package/src/server/routes/settings/commands.ts +1 -1
  55. package/src/server/routes/terminal/index.ts +2 -2
  56. package/src/server/routes/terminal/run.ts +8 -100
  57. package/src/server/routes/terminal/sessions.ts +24 -0
  58. package/src/server/routes/terminal/ws.ts +149 -0
  59. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  60. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  61. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  62. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  63. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  64. package/src/shared/lib/workspace/session-state/store.ts +1 -4
  65. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  66. package/src/shared/types/omp/agent.ts +12 -0
  67. package/src/shared/types/terminal.ts +17 -15
  68. package/src/shared/types/workspace.ts +2 -2
  69. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  70. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  71. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  72. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  73. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  74. package/src/client/hooks/workspace/terminal.ts +0 -283
  75. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  76. package/src/server/routes/terminal/stream.ts +0 -100
  77. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  78. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  79. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
@@ -4,84 +4,148 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Follow-up + steering queue state for the chamber chat, backed by the SQLite
8
- * `queued_messages` table via /api/sessions/:id/queue (one row per item with a
9
- * model snapshot, so auto-delivery replays the settings the item was queued
10
- * with).
7
+ * Client view of the follow-up queue (SQLite `queued_messages`, owned by the
8
+ * server). The panel is a VIEW: mutations go through the per-item endpoints
9
+ * (`POST/PATCH/DELETE /api/sessions/:id/queue[/:itemId]`) and every response
10
+ * carries the canonical queue, which replaces local state wholesale. The old
11
+ * client-side replace-on-write mirror is gone — it was what let a reload
12
+ * hydrating over an empty state wipe the table, and a session switch PUT one
13
+ * session's list into another's rows.
11
14
  *
12
- * The client panel stays the source of truth while the session is open: every
13
- * mutation updates React state first, then PUTs the full remaining list to the
14
- * route (replace-on-write keeps ids stable for reorder/edit/delete). The queue
15
- * hydrates from the table on mount — a page reload mid-stream restores it.
16
- * Steering mirrors live in the session-state blob under a separate key.
17
- *
18
- * Kept out of useChatTimeline so that hook stays under the size ceiling.
15
+ * Steering still mirrors into the per-session session-state blob (client-only
16
+ * by design: an interrupt-and-reply is only meaningful while a client watches
17
+ * the run).
19
18
  */
20
19
 
21
- import type { SetStateAction } from 'preact/compat';
22
20
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
23
21
  import type { QueuedMessage } from '@/shared/types';
24
22
  import { useSessionState } from '@/client/hooks/workspace/session-state';
25
23
 
26
24
  const STEERING_STATE_KEY = 'chat.steeringQueue';
27
25
 
26
+ function queueUrl(sessionId: string): string {
27
+ return `/api/sessions/${encodeURIComponent(sessionId)}/queue`;
28
+ }
29
+
30
+ async function readQueueResponse(res: Response): Promise<QueuedMessage[] | null> {
31
+ if (!res.ok) return null;
32
+ const data = (await res.json().catch(() => null)) as { queue?: unknown } | null;
33
+ return Array.isArray(data?.queue) ? (data.queue as QueuedMessage[]) : null;
34
+ }
35
+
28
36
  export interface ChatTimelineQueueResult {
29
37
  messageQueue: QueuedMessage[];
30
- setMessageQueue: (updater: SetStateAction<QueuedMessage[]>) => void;
38
+ /** Append one item; local state updates optimistically, then reconciles
39
+ * from the canonical response. No-op without a session. */
40
+ enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
41
+ /** Edit one item's text (PATCH). */
42
+ editMessageText: (id: string, text: string) => void;
43
+ /** Remove one item by id (DELETE). */
44
+ removeMessage: (id: string) => void;
45
+ /** Reorder the queue to these ids (PUT, drag-and-drop). */
46
+ reorderMessages: (orderedIds: string[]) => void;
47
+ /** Replace the local list from a fetch (mount hydration, manual refresh). */
48
+ refresh: () => void;
49
+ /** Client-only steering mirror (session-state blob). */
31
50
  steeringQueue: QueuedMessage[];
32
- setSteeringQueue: (updater: SetStateAction<QueuedMessage[]>) => void;
33
- /** Drop an exactly-matching text from whichever mirror queue holds it. */
34
- removeDeliveredFromQueue: (text: string) => void;
51
+ setSteeringQueue: (value: QueuedMessage[] | ((prev: QueuedMessage[]) => QueuedMessage[])) => void;
35
52
  }
36
53
 
37
54
  export function useChatTimelineQueue(sessionId: string | null): ChatTimelineQueueResult {
38
55
  const [messageQueue, setMessageQueueState] = useState<QueuedMessage[]>([]);
39
56
  const [steeringQueue, setSteeringQueue] = useSessionState<QueuedMessage[]>(STEERING_STATE_KEY, []);
40
57
 
41
- // Hydrate from the table once per session. A generation-safe ref skips the
42
- // stale-response race when the user switches sessions quickly.
58
+ // Hydrate from the table once per session, and re-read when the tab
59
+ // regains focus (a second tab may have claimed/delivered the head). A
60
+ // generation-safe ref skips the stale-response race on quick switches.
43
61
  const hydrateEpochRef = useRef(0);
44
- useEffect(() => {
62
+ const cancelledRef = useRef(false);
63
+ const refresh = useCallback(() => {
45
64
  if (!sessionId) {
46
65
  setMessageQueueState([]);
47
66
  return;
48
67
  }
49
68
  const epoch = ++hydrateEpochRef.current;
50
- let cancelled = false;
51
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/queue`)
52
- .then(res => (res.ok ? res.json() : { queue: [] }))
53
- .then((data: { queue?: QueuedMessage[] }) => {
54
- if (cancelled || hydrateEpochRef.current !== epoch) return;
55
- setMessageQueueState(Array.isArray(data.queue) ? data.queue : []);
69
+ fetch(queueUrl(sessionId))
70
+ .then((res) => readQueueResponse(res))
71
+ .then((queue) => {
72
+ if (cancelledRef.current || hydrateEpochRef.current !== epoch) return;
73
+ setMessageQueueState(queue ?? []);
56
74
  })
57
75
  .catch(() => {});
58
- return () => { cancelled = true; };
59
76
  }, [sessionId]);
77
+ useEffect(() => {
78
+ cancelledRef.current = false;
79
+ refresh();
80
+ const onFocus = () => refresh();
81
+ window.addEventListener('focus', onFocus);
82
+ return () => {
83
+ cancelledRef.current = true;
84
+ window.removeEventListener('focus', onFocus);
85
+ };
86
+ }, [refresh]);
60
87
 
61
- // Replace-on-write mirror: every client mutation PUTs the full list so the
62
- // table always matches the panel (order, edits, deletions included).
63
- const mirrorQueue = useCallback((next: QueuedMessage[]) => {
88
+ // Mounted session with queued items: offer the server a delivery slot. The
89
+ // server-side claim makes this harmless when a run is active or another tab
90
+ // already took the head — this used to be the client auto-delivery effect,
91
+ // which fired into whatever session was open at the time.
92
+ useEffect(() => {
93
+ if (!sessionId || messageQueue.length === 0) return;
94
+ fetch(`${queueUrl(sessionId)}/deliver`, { method: 'POST' }).catch(() => {});
95
+ }, [sessionId, messageQueue.length > 0]);
96
+
97
+ const enqueueMessage = useCallback((item: Omit<QueuedMessage, 'id'>) => {
64
98
  if (!sessionId) return;
65
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/queue`, {
66
- method: 'PUT',
99
+ const optimistic: QueuedMessage = { ...item, id: `queue-${Date.now()}` };
100
+ setMessageQueueState((prev) => [...prev, optimistic]);
101
+ fetch(queueUrl(sessionId), {
102
+ method: 'POST',
67
103
  headers: { 'Content-Type': 'application/json' },
68
- body: JSON.stringify({ queue: next }),
69
- }).catch(console.error);
104
+ body: JSON.stringify(item),
105
+ })
106
+ .then((res) => readQueueResponse(res))
107
+ .then((queue) => { if (queue) setMessageQueueState(queue); })
108
+ .catch(() => {});
70
109
  }, [sessionId]);
71
110
 
72
- const setMessageQueue = useCallback((updater: SetStateAction<QueuedMessage[]>) => {
73
- setMessageQueueState(prev => {
74
- const next = typeof updater === 'function' ? updater(prev) : updater;
75
- mirrorQueue(next);
76
- return next;
77
- });
78
- }, [mirrorQueue]);
111
+ const editMessageText = useCallback((id: string, text: string) => {
112
+ if (!sessionId) return;
113
+ fetch(`${queueUrl(sessionId)}/${encodeURIComponent(id)}`, {
114
+ method: 'PATCH',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ text }),
117
+ })
118
+ .then((res) => readQueueResponse(res))
119
+ .then((queue) => { if (queue) setMessageQueueState(queue); })
120
+ .catch(() => {});
121
+ }, [sessionId]);
122
+
123
+ const removeMessage = useCallback((id: string) => {
124
+ if (!sessionId) return;
125
+ // Optimistic removal, reconciled only on failure (404 = already claimed —
126
+ // the item is gone either way).
127
+ setMessageQueueState((prev) => prev.filter((i) => i.id !== id));
128
+ fetch(`${queueUrl(sessionId)}/${encodeURIComponent(id)}`, { method: 'DELETE' })
129
+ .then((res) => readQueueResponse(res))
130
+ .then((queue) => { if (queue) setMessageQueueState(queue); })
131
+ .catch(() => {});
132
+ }, [sessionId]);
79
133
 
80
- const removeDeliveredFromQueue = useCallback((text: string) => {
81
- const exact = (q: QueuedMessage[]) => q.some(i => i.text === text);
82
- setSteeringQueue(q => (exact(q) ? q.filter(i => i.text !== text) : q));
83
- setMessageQueue(q => (exact(q) ? q.filter(i => i.text !== text) : q));
84
- }, [setSteeringQueue, setMessageQueue]);
134
+ const reorderMessages = useCallback((orderedIds: string[]) => {
135
+ if (!sessionId) return;
136
+ setMessageQueueState((prev) => {
137
+ const byId = new Map(prev.map((i) => [i.id, i]));
138
+ return orderedIds.map((id) => byId.get(id)).filter((i): i is QueuedMessage => Boolean(i));
139
+ });
140
+ fetch(queueUrl(sessionId), {
141
+ method: 'PUT',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: JSON.stringify({ orderedIds }),
144
+ })
145
+ .then((res) => readQueueResponse(res))
146
+ .then((queue) => { if (queue) setMessageQueueState(queue); })
147
+ .catch(() => {});
148
+ }, [sessionId]);
85
149
 
86
- return { messageQueue, setMessageQueue, steeringQueue, setSteeringQueue, removeDeliveredFromQueue };
150
+ return { messageQueue, enqueueMessage, editMessageText, removeMessage, reorderMessages, refresh, steeringQueue, setSteeringQueue };
87
151
  }
@@ -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
+ }
@@ -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