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
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Terminal WebSocket: the only transport between a terminal panel and its PTY.
8
+ *
9
+ * Text frames are JSON control, binary frames are raw PTY bytes in both
10
+ * directions. The client's first frame is `attach`, which also creates the
11
+ * shell when the id is new — one round trip, and no separate create endpoint to
12
+ * race against.
13
+ *
14
+ * The upgrade is origin-checked. Browsers happily open a WebSocket to
15
+ * `localhost` from any page, and there is no preflight to stop them, so a
16
+ * cross-origin page could otherwise drive a shell on this machine.
17
+ *
18
+ * Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the `ws`
19
+ * object: Elysia hands a *different* wrapper to `message`/`close` than the one
20
+ * `open` received (verified on 1.4.30), so a WeakMap lookup finds nothing and
21
+ * every frame is silently dropped. `ws.data` is the same object throughout.
22
+ */
23
+
24
+ import { Elysia, t } from 'elysia';
25
+ import {
26
+ attachTerminal,
27
+ closeTerminal,
28
+ detachTerminal,
29
+ resizeTerminalDebounced,
30
+ writeTerminalInput,
31
+ } from '@/server/lib/terminal/runtime.server';
32
+ import { TerminalRuntimeError, type TerminalViewer } from '@/server/lib/terminal/session.server';
33
+ import {
34
+ decodeClientFrame,
35
+ encodeServerFrame,
36
+ isValidTerminalId,
37
+ } from '@/shared/lib/workspace/terminal/protocol';
38
+
39
+ interface Attachment {
40
+ id: string;
41
+ viewer: TerminalViewer;
42
+ /** One attach per socket; a duplicate would replay the scrollback twice. */
43
+ attached: boolean;
44
+ }
45
+
46
+ /** Elysia's ws context, narrowed to the per-connection store this route adds. */
47
+ type TerminalWsData = { params: { terminalId: string }; terminal?: Attachment };
48
+
49
+ export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:terminalId/ws', {
50
+ params: t.Object({ terminalId: t.String() }),
51
+
52
+ beforeHandle({ request, status }) {
53
+ if (!isSameOriginUpgrade(request)) return status(403, 'Cross-origin terminal upgrade rejected');
54
+ },
55
+
56
+ open(ws) {
57
+ const data = ws.data as unknown as TerminalWsData;
58
+ const id = data.params.terminalId;
59
+ if (!isValidTerminalId(id)) {
60
+ ws.close();
61
+ return;
62
+ }
63
+ data.terminal = {
64
+ id,
65
+ attached: false,
66
+ viewer: {
67
+ send(frame) {
68
+ try {
69
+ ws.send(frame);
70
+ } catch {
71
+ // Socket closed mid-send; the close handler detaches.
72
+ }
73
+ },
74
+ },
75
+ };
76
+ },
77
+
78
+ async message(ws, raw) {
79
+ const attachment = (ws.data as unknown as TerminalWsData).terminal;
80
+ if (!attachment) return;
81
+
82
+ // Raw PTY input (keystrokes, paste, mouse reports) — never JSON-escaped.
83
+ if (raw instanceof Uint8Array) {
84
+ writeTerminalInput(attachment.id, raw);
85
+ return;
86
+ }
87
+
88
+ const frame = decodeClientFrame(raw);
89
+ if (!frame) return;
90
+
91
+ if (frame.t === 'resize') {
92
+ resizeTerminalDebounced(attachment.id, frame.cols, frame.rows);
93
+ return;
94
+ }
95
+
96
+ if (frame.t === 'close') {
97
+ closeTerminal(attachment.id);
98
+ return;
99
+ }
100
+
101
+ if (attachment.attached) return;
102
+
103
+ try {
104
+ const { snapshot, replay } = await attachTerminal(attachment.viewer, {
105
+ id: attachment.id,
106
+ cols: frame.cols,
107
+ rows: frame.rows,
108
+ root: frame.root,
109
+ repo: frame.repo,
110
+ theme: frame.theme,
111
+ });
112
+ attachment.attached = true;
113
+ ws.send(encodeServerFrame({ t: 'ready', ...snapshot, replayBytes: replay.length }));
114
+ // Ordering is safe without sequence numbers: attach captured the history
115
+ // and registered this viewer in one synchronous block, so nothing can be
116
+ // delivered between this frame and the replay that follows it.
117
+ if (replay.length > 0) ws.send(replay);
118
+ } catch (error) {
119
+ const code = error instanceof TerminalRuntimeError ? error.code : 'unknown';
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ ws.send(encodeServerFrame({ t: 'error', code, message }));
122
+ }
123
+ },
124
+
125
+ close(ws) {
126
+ const data = ws.data as unknown as TerminalWsData;
127
+ const attachment = data.terminal;
128
+ if (!attachment) return;
129
+ data.terminal = undefined;
130
+ detachTerminal(attachment.viewer, attachment.id);
131
+ },
132
+ });
133
+
134
+ /**
135
+ * A WebSocket upgrade carries the page's `Origin`, and no preflight protects
136
+ * it. Requests without one (curl, tests) are allowed: they are not a browser
137
+ * being tricked into dialing localhost.
138
+ */
139
+ function isSameOriginUpgrade(request: Request): boolean {
140
+ const origin = request.headers.get('origin');
141
+ if (!origin) return true;
142
+ const host = request.headers.get('host');
143
+ if (!host) return false;
144
+ try {
145
+ return new URL(origin).host === host;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
@@ -25,10 +25,14 @@ const ABORTED_TURN = {
25
25
  function makeDeps() {
26
26
  const ended: ChatMessageData[] = [];
27
27
  const activity: string[] = [];
28
+ const commandOutputs: string[] = [];
29
+ let settledCount = 0;
28
30
  let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
29
31
  const callbacks: OmpAgentCallbacks = {
30
32
  onMessageEnd: (msg) => ended.push(msg),
31
33
  onActivity: (verb) => activity.push(verb),
34
+ onPromptSettled: () => { settledCount += 1; },
35
+ onCommandOutput: (text) => commandOutputs.push(text),
32
36
  };
33
37
  const deps: OmpAgentFoldDeps = {
34
38
  sessionId: 's1',
@@ -43,7 +47,7 @@ function makeDeps() {
43
47
  currentThinkingLevelRef: { current: undefined },
44
48
  fileMutatingCallsRef: { current: new Set<string>() },
45
49
  };
46
- return { deps, ended, activity };
50
+ return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
47
51
  }
48
52
 
49
53
  describe('toChatMessage error derivation', () => {
@@ -60,6 +64,38 @@ describe('toChatMessage error derivation', () => {
60
64
  });
61
65
  });
62
66
 
67
+ describe('foldAgentEvent prompt_result (builtin slash commands)', () => {
68
+ test('agentInvoked:false settles generating state and fires onPromptSettled', () => {
69
+ const { deps, state, settled } = makeDeps();
70
+ foldAgentEvent({ type: 'agent_start' }, deps);
71
+ expect(state().isGenerating).toBe(true);
72
+ foldAgentEvent({ type: 'prompt_result', agentInvoked: false }, deps);
73
+ expect(state().isGenerating).toBe(false);
74
+ expect(settled()).toBe(1);
75
+ });
76
+
77
+ test('prompt_result with agentInvoked:true does not settle (a run follows)', () => {
78
+ const { deps, settled } = makeDeps();
79
+ foldAgentEvent({ type: 'prompt_result', agentInvoked: true }, deps);
80
+ expect(settled()).toBe(0);
81
+ });
82
+ });
83
+
84
+ describe('foldAgentEvent command_output (builtin slash output)', () => {
85
+ test('forwards trimmed text to onCommandOutput', () => {
86
+ const { deps, commandOutputs } = makeDeps();
87
+ foldAgentEvent({ type: 'command_output', text: 'Usage\nInput tokens: 0\n' }, deps);
88
+ expect(commandOutputs).toEqual(['Usage\nInput tokens: 0']);
89
+ });
90
+
91
+ test('drops empty output frames', () => {
92
+ const { deps, commandOutputs } = makeDeps();
93
+ foldAgentEvent({ type: 'command_output', text: ' ' }, deps);
94
+ foldAgentEvent({ type: 'command_output' }, deps);
95
+ expect(commandOutputs).toEqual([]);
96
+ });
97
+ });
98
+
63
99
  describe('foldAgentEvent activity phrases', () => {
64
100
  test('names the tool being executed, not a generic reasoning verb', () => {
65
101
  const { deps, activity } = makeDeps();
@@ -165,6 +165,11 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
165
165
  callbacks?.onAgentStart?.();
166
166
  break;
167
167
 
168
+ case 'turn_start':
169
+ setActivity(PHASE_VERBS.thinking, deps);
170
+ callbacks?.onTurnStart?.();
171
+ break;
172
+
168
173
  case 'message_start':
169
174
  case 'message_update': {
170
175
  const msg = data.message as Record<string, unknown> | undefined;
@@ -177,6 +182,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
177
182
  // Assistant phases (thinking / prose / tool-call assembly) name the
178
183
  // action directly — a tool that never starts still shows its phrase.
179
184
  setActivity(describeAssistantPhase(data.assistantMessageEvent), deps);
185
+ // The first message frame of a turn is another proof the run is live —
186
+ // re-signal so the sidebar can restore a lost `stream` status row.
187
+ // message_update frames arrive per delta and must stay silent here.
188
+ if (data.type === 'message_start') callbacks?.onTurnStart?.();
180
189
  // Steering (abort_and_prompt) and follow-up deliveries create no
181
190
  // optimistic bubble — the user turn only exists on the stream. Convert
182
191
  // it here so the bubble renders live instead of after a JSONL reload.
@@ -298,6 +307,27 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
298
307
  break;
299
308
  }
300
309
 
310
+ // omp's prompt ack ran a built-in slash command instead of an agent turn
311
+ // (`agentInvoked:false`, mirrored server-side as this frame): there will be
312
+ // no agent_start/agent_end pair. Clear the optimistic generating state —
313
+ // the callbacks drop the AI placeholder and keep the user's message row.
314
+ case 'prompt_result': {
315
+ if (data.agentInvoked === false) {
316
+ deps.setState((prev) => ({ ...prev, isGenerating: false }));
317
+ deps.activityRef.current = '';
318
+ callbacks?.onPromptSettled?.();
319
+ }
320
+ break;
321
+ }
322
+
323
+ // Built-in slash command output (/usage, /compact result, …). Rendered as
324
+ // a notice row — see the callback type note: this frame is the ONLY copy.
325
+ case 'command_output': {
326
+ const text = typeof data.text === 'string' ? data.text.trim() : '';
327
+ if (text) callbacks?.onCommandOutput?.(text);
328
+ break;
329
+ }
330
+
301
331
  case 'notice': {
302
332
  callbacks?.onNotice?.(
303
333
  typeof data.level === 'string' ? data.level : 'info',
@@ -63,7 +63,6 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
63
63
 
64
64
  function makeCallbacks(): OmpAgentCallbacks {
65
65
  const deps: OmpAgentCallbacksDeps = {
66
- removeDeliveredFromQueue: () => {},
67
66
  setGenerating: () => {},
68
67
  setGeneratingVerb: () => {},
69
68
  scrollToBottom: () => {},
@@ -133,6 +132,15 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
133
132
  expect(signals).toHaveLength(2);
134
133
  });
135
134
 
135
+ test('onTurnStart signals the sidebar on every turn of the run', () => {
136
+ const callbacks = makeCallbacks();
137
+
138
+ callbacks.onTurnStart?.();
139
+ callbacks.onTurnStart?.();
140
+ expect(signals).toHaveLength(2);
141
+ expect(signals.every(s => s.name === 'omp:session-updated' && s.sessionId === 'sess-1')).toBe(true);
142
+ });
143
+
136
144
  test('never signals from user turns or notice rows alone', () => {
137
145
  const callbacks = makeCallbacks();
138
146
 
@@ -47,7 +47,6 @@ export function cancelStreamingCoalescer(): void {
47
47
  }
48
48
 
49
49
  export interface OmpAgentCallbacksDeps {
50
- removeDeliveredFromQueue: (text: string) => void;
51
50
  setGenerating: (v: boolean) => void;
52
51
  setGeneratingVerb: (v: string) => void;
53
52
  scrollToBottom: (behavior?: ScrollBehavior) => void;
@@ -73,7 +72,6 @@ export interface OmpAgentCallbacksDeps {
73
72
 
74
73
  export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
75
74
  const {
76
- removeDeliveredFromQueue,
77
75
  setGenerating,
78
76
  setGeneratingVerb,
79
77
  scrollToBottom,
@@ -97,12 +95,6 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
97
95
  scrollAfterFlush = () => scrollToBottom('smooth');
98
96
 
99
97
  return {
100
- // A queued steer/follow-up text was picked up by the agent (user turn
101
- // arrived) — drop it from whichever queue mirrors it so the panel stays
102
- // truthful without a chat bubble for every delivery.
103
- onQueuedMessageDelivered: (text) => {
104
- removeDeliveredFromQueue(text);
105
- },
106
98
  onAgentStart: () => {
107
99
  setGenerating(true);
108
100
  setGeneratingVerb(PHASE_VERBS.thinking);
@@ -122,6 +114,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
122
114
  }
123
115
  }
124
116
  },
117
+ onTurnStart: () => {
118
+ // Every turn inside the run re-signals the sidebar: the throttle makes
119
+ // this cheap, and it recovers the `stream` status row if a previous
120
+ // dispatch raced with the status write (or the row was healed away).
121
+ const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
122
+ if (sid && typeof window !== 'undefined') {
123
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
124
+ }
125
+ },
125
126
  // Reload recovery: the omp process kept running server-side, so the event
126
127
  // stream is reattached and the generating UI must resume (the timeline
127
128
  // fetch already loaded the committed messages; live updates continue).
@@ -308,6 +309,42 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
308
309
  abortControllerRef.current = null;
309
310
  console.error('OMP prompt error:', errorMessage);
310
311
  },
312
+ // The prompt ran a built-in slash command (omp answered on the command
313
+ // path, no agent run): drop the optimistic AI placeholder and settle the
314
+ // optimistic user row — it stays as the record of what the user invoked.
315
+ onPromptSettled: () => {
316
+ disposeStreamingCoalescer();
317
+ setGenerating(false);
318
+ abortControllerRef.current = null;
319
+ optimisticUserIdRef.current = null;
320
+ const placeholderId = aiPlaceholderIdRef.current;
321
+ aiPlaceholderIdRef.current = null;
322
+ if (placeholderId) {
323
+ setLocalMessages(prev => {
324
+ if (!prev.some(m => m.id === placeholderId)) return prev;
325
+ return prev.filter(m => m.id !== placeholderId);
326
+ });
327
+ }
328
+ },
329
+ // Built-in slash command output only rides the live stream — append it as
330
+ // a notice row before any active placeholder, exactly like omp notices.
331
+ onCommandOutput: (text) => {
332
+ const row: ChatMessageData = {
333
+ id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
334
+ role: 'ai',
335
+ content: '',
336
+ notice: text,
337
+ };
338
+ setLocalMessages(prev => {
339
+ const placeholderId = aiPlaceholderIdRef.current;
340
+ if (placeholderId) {
341
+ const pIdx = prev.findIndex(m => m.id === placeholderId);
342
+ if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
343
+ }
344
+ if (prev.some(m => m.notice === text)) return prev;
345
+ return [...prev, row];
346
+ });
347
+ },
311
348
  onNotice: (_level, message) => {
312
349
  console.info('OMP notice:', message);
313
350
  },
@@ -11,7 +11,6 @@
11
11
  * stream — a run is in flight (prompt dispatch → agent_start)
12
12
  * finish — the run ended normally (agent_end), not yet seen
13
13
  * abort — the user stopped it, not yet seen
14
- * error — the run failed (prompt_error / process exit), not yet seen
15
14
  *
16
15
  * A terminal status is a one-shot badge: opening the session acknowledges it
17
16
  * (`markStreamSeen` deletes the row), which is exactly the "check appears
@@ -25,7 +24,7 @@
25
24
 
26
25
  import { getDb } from '@/server/db.server';
27
26
 
28
- export type SessionStreamStatus = 'stream' | 'finish' | 'abort' | 'error';
27
+ export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
29
28
 
30
29
  export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
31
30
  try {
@@ -87,7 +86,7 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
87
86
  }[];
88
87
  const map: Record<string, SessionStreamStatus> = {};
89
88
  for (const row of rows) {
90
- if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort' || row.status === 'error') {
89
+ if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort') {
91
90
  map[row.session_id] = row.status;
92
91
  }
93
92
  }
@@ -28,10 +28,7 @@
28
28
  * - userBrowser.viewportMode string
29
29
  * - userBrowser.zoomLevel number
30
30
  * - terminal.activeRepo string
31
- * - terminal.cwd string
32
- * - terminal.commandHistory string[]
33
- * - terminal.input string
34
- * - terminal.output string (trimmed tail)
31
+ * - terminal.id string (server-side PTY id; reattached on reload)
35
32
  * - git.viewMode 'flat' | 'tree'
36
33
  * - git.commitDraft string
37
34
  * - git.stagedExpanded boolean
@@ -0,0 +1,222 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Wire contract for the terminal socket — the only transport a terminal panel
8
+ * uses.
9
+ *
10
+ * Two frame kinds share one duplex socket:
11
+ *
12
+ * - **text** frames are JSON control (`attach`, `resize`, `close` upstream;
13
+ * `ready`, `exit`, `error` downstream). Elysia hands inbound JSON to the
14
+ * handler already parsed and inbound binary as a `Uint8Array`, so the split
15
+ * is unambiguous on both ends.
16
+ * - **binary** frames are raw PTY bytes in both directions. Keystrokes are
17
+ * never JSON-escaped, so `\x03`, `\x1b[A` and paste payloads reach the shell
18
+ * byte-for-byte, and output is written straight into xterm without a decode
19
+ * step in between.
20
+ *
21
+ * The first binary frame after `ready` is the retained scrollback (it may be
22
+ * empty); everything after it is live output.
23
+ */
24
+
25
+ import { isRecord } from '@/shared/lib/util/guards';
26
+
27
+ /** Grid bounds both ends clamp to, so a bogus size can never reach the PTY. */
28
+ export const TERMINAL_MIN_COLS = 2;
29
+ export const TERMINAL_MAX_COLS = 1000;
30
+ export const TERMINAL_MIN_ROWS = 1;
31
+ export const TERMINAL_MAX_ROWS = 500;
32
+
33
+ /** Largest single input frame accepted from a client (keystrokes or paste). */
34
+ export const TERMINAL_MAX_INPUT_BYTES = 64 * 1024;
35
+
36
+ /** Ids key a server-side map and appear in a path segment. */
37
+ const TERMINAL_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
38
+
39
+ export type TerminalStatus = 'running' | 'exited';
40
+
41
+ export interface TerminalSnapshot {
42
+ id: string;
43
+ /** Working directory the shell was launched in. */
44
+ cwd: string;
45
+ shell: string;
46
+ cols: number;
47
+ rows: number;
48
+ status: TerminalStatus;
49
+ exitCode: number | null;
50
+ signal: string | null;
51
+ bunVersion: string;
52
+ nodeVersion: string;
53
+ /**
54
+ * True when a command holds the terminal's foreground. An idle shell sitting
55
+ * at its prompt is running but not busy, which is the distinction the cap and
56
+ * the status badge both use.
57
+ */
58
+ busy?: boolean;
59
+ /**
60
+ * Size the retained scrollback was drawn for. The client replays at this
61
+ * size and only then fits its own viewport: re-wrapping history drawn for
62
+ * another width leaves fragments the shell's own redraw never clears.
63
+ */
64
+ replayCols: number;
65
+ replayRows: number;
66
+ }
67
+
68
+ export interface TerminalAttachFrame {
69
+ t: 'attach';
70
+ cols: number;
71
+ rows: number;
72
+ root?: string;
73
+ repo?: string;
74
+ theme?: 'light' | 'dark';
75
+ }
76
+
77
+ export interface TerminalResizeFrame {
78
+ t: 'resize';
79
+ cols: number;
80
+ rows: number;
81
+ }
82
+
83
+ export interface TerminalCloseFrame {
84
+ t: 'close';
85
+ }
86
+
87
+ export type TerminalClientFrame = TerminalAttachFrame | TerminalResizeFrame | TerminalCloseFrame;
88
+
89
+ export interface TerminalReadyFrame extends TerminalSnapshot {
90
+ t: 'ready';
91
+ /**
92
+ * Length of the replay frame that follows. Zero means the scrollback is
93
+ * empty; either way the client counts these bytes before it treats anything
94
+ * as live output, so it can replay at `replayCols`/`replayRows` first.
95
+ */
96
+ replayBytes: number;
97
+ }
98
+
99
+ export interface TerminalExitFrame {
100
+ t: 'exit';
101
+ exitCode: number | null;
102
+ signal: string | null;
103
+ }
104
+
105
+ export type TerminalErrorCode = 'capacity' | 'cwd' | 'shell' | 'spawn' | 'unknown';
106
+
107
+ export interface TerminalErrorFrame {
108
+ t: 'error';
109
+ code: TerminalErrorCode;
110
+ message: string;
111
+ }
112
+
113
+ export type TerminalServerFrame = TerminalReadyFrame | TerminalExitFrame | TerminalErrorFrame;
114
+
115
+ /**
116
+ * Terminal ids are client-generated. `randomUUID` needs a secure context, and
117
+ * the chamber is reachable over plain http on a LAN address (`--lan`), so the
118
+ * fallback is what keeps that deployment working.
119
+ */
120
+ export function newTerminalId(): string {
121
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
122
+ return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
123
+ }
124
+
125
+ export function isValidTerminalId(value: string): boolean {
126
+ return TERMINAL_ID_PATTERN.test(value);
127
+ }
128
+
129
+ /** Clamp a proposed grid to the bounds the PTY runtime accepts. */
130
+ export function clampTerminalSize(cols: number, rows: number): { cols: number; rows: number } {
131
+ return {
132
+ cols: clampDimension(cols, TERMINAL_MIN_COLS, TERMINAL_MAX_COLS),
133
+ rows: clampDimension(rows, TERMINAL_MIN_ROWS, TERMINAL_MAX_ROWS),
134
+ };
135
+ }
136
+
137
+ export function encodeServerFrame(frame: TerminalServerFrame): string {
138
+ return JSON.stringify(frame);
139
+ }
140
+
141
+ export function encodeClientFrame(frame: TerminalClientFrame): string {
142
+ return JSON.stringify(frame);
143
+ }
144
+
145
+ /** Decode a control frame from either a parsed object or raw JSON text. */
146
+ export function decodeClientFrame(raw: unknown): TerminalClientFrame | null {
147
+ const value = typeof raw === 'string' ? tryParse(raw) : raw;
148
+ if (!isRecord(value)) return null;
149
+
150
+ if (value.t === 'attach') {
151
+ const cols = readDimension(value.cols);
152
+ const rows = readDimension(value.rows);
153
+ if (cols === null || rows === null) return null;
154
+ const frame: TerminalAttachFrame = { t: 'attach', cols, rows };
155
+ if (typeof value.root === 'string') frame.root = value.root;
156
+ if (typeof value.repo === 'string') frame.repo = value.repo;
157
+ if (value.theme === 'light' || value.theme === 'dark') frame.theme = value.theme;
158
+ return frame;
159
+ }
160
+
161
+ if (value.t === 'resize') {
162
+ const cols = readDimension(value.cols);
163
+ const rows = readDimension(value.rows);
164
+ if (cols === null || rows === null) return null;
165
+ return { t: 'resize', cols, rows };
166
+ }
167
+
168
+ if (value.t === 'close') return { t: 'close' };
169
+ return null;
170
+ }
171
+
172
+ export function decodeServerFrame(raw: string): TerminalServerFrame | null {
173
+ const value = tryParse(raw);
174
+ if (!isRecord(value)) return null;
175
+
176
+ if (value.t === 'ready') {
177
+ if (typeof value.id !== 'string' || typeof value.cwd !== 'string') return null;
178
+ const replayBytes = typeof value.replayBytes === 'number' ? value.replayBytes : 0;
179
+ return { ...(value as unknown as TerminalSnapshot), t: 'ready', replayBytes };
180
+ }
181
+
182
+ if (value.t === 'exit') {
183
+ return {
184
+ t: 'exit',
185
+ exitCode: typeof value.exitCode === 'number' ? value.exitCode : null,
186
+ signal: typeof value.signal === 'string' ? value.signal : null,
187
+ };
188
+ }
189
+
190
+ if (value.t === 'error' && typeof value.message === 'string') {
191
+ return { t: 'error', code: readErrorCode(value.code), message: value.message };
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ /** WebSocket endpoint for one terminal. Browser-only: reads `window.location`. */
198
+ export function terminalSocketUrl(terminalId: string): string {
199
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
200
+ return `${protocol}//${window.location.host}/api/terminal/${encodeURIComponent(terminalId)}/ws`;
201
+ }
202
+
203
+ function clampDimension(value: number, min: number, max: number): number {
204
+ if (!Number.isFinite(value)) return min;
205
+ return Math.min(max, Math.max(min, Math.trunc(value)));
206
+ }
207
+
208
+ function readDimension(value: unknown): number | null {
209
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
210
+ }
211
+
212
+ function readErrorCode(value: unknown): TerminalErrorCode {
213
+ return value === 'capacity' || value === 'cwd' || value === 'shell' || value === 'spawn' ? value : 'unknown';
214
+ }
215
+
216
+ function tryParse(raw: string): unknown {
217
+ try {
218
+ return JSON.parse(raw);
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
@@ -22,10 +22,22 @@ export interface OmpAgentEvent {
22
22
  * into ChatMessageData lands on one of these. */
23
23
  export interface OmpAgentCallbacks {
24
24
  onAgentStart?: () => void;
25
+ /** A single turn inside the run began (multi-turn runs emit several). */
26
+ onTurnStart?: () => void;
25
27
  onMessageUpdate?: (msg: ChatMessageData) => void;
26
28
  onMessageEnd?: (msg: ChatMessageData) => void;
27
29
  onAgentEnd?: (info: { errorMessage?: string; message?: string }) => void;
28
30
  onPromptError?: (errorMessage: string) => void;
31
+ /** The dispatched prompt ran no agent turn: omp executed a built-in slash
32
+ * command itself (ack `agentInvoked:false`, e.g. /usage, /compact). No
33
+ * agent_start/agent_end follows, so the timeline must clear the optimistic
34
+ * generating state here or the AI placeholder spins forever. */
35
+ onPromptSettled?: () => void;
36
+ /** Output of a built-in slash command omp executed on the prompt path
37
+ * (`command_output` frame — /usage, /model, /compact result, …). Lives only
38
+ * on the live stream: it never enters get_state.messages or the session
39
+ * JSONL, so the timeline must render it from this frame or it is lost. */
40
+ onCommandOutput?: (text: string) => void;
29
41
  onNotice?: (level: string, message: string) => void;
30
42
  onConnected?: () => void;
31
43
  /** Mount-time probe found the session mid-run → the stream was reattached