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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/{index.5c0447cefe.css → index.e880517fcd.css} +1 -1
- 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.dc5f5fabe8.js → index.0de15c5e17.js} +10 -10
- package/package.json +2 -1
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- 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 +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/index.tsx +4 -2
- 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/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/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 +23 -25
- package/src/client/hooks/chat/timeline/index.ts +12 -20
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- 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/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.ts +114 -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/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 +1 -1
- 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/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/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- 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/{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.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'
|
|
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'
|
|
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.
|
|
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
|