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,73 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Is a shell busy, or sitting at its prompt?
8
+ *
9
+ * The PTY's foreground process group answers this exactly, and the kernel keeps
10
+ * it up to date: when a command runs in the foreground the terminal's `tpgid`
11
+ * becomes that command's group, and when the shell regains control `tpgid`
12
+ * returns to the shell's own pid. Verified on macOS against a real PTY —
13
+ * `sleep 30` moved `tpgid` off the shell's pid, `Ctrl+C` and a backgrounded job
14
+ * both left it on the shell's pid (a background job is by definition not the
15
+ * foreground job).
16
+ *
17
+ * Why it matters: the terminal cap counts *live* shells. "Live" has to mean
18
+ * "has work in flight", otherwise a panel that was opened, restarted, or
19
+ * switched a few times pins the budget with shells that are all idle at a
20
+ * prompt, and the next attach is rejected with nothing actually running.
21
+ *
22
+ * Cost is one `ps` call for the whole registry, so this is safe to run on every
23
+ * attach. It is advisory: any failure resolves to "not busy", because refusing
24
+ * a shell to save a slot is worse than letting one extra through.
25
+ */
26
+
27
+ const PS_TIMEOUT_MS = 1500;
28
+
29
+ /** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
30
+ export interface ShellProcess {
31
+ id: string;
32
+ pid: number;
33
+ }
34
+
35
+ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
36
+ const busy = new Set<string>();
37
+ if (shells.length === 0 || process.platform === 'win32') return busy;
38
+
39
+ const byPid = new Map<number, string>();
40
+ for (const shell of shells) byPid.set(shell.pid, shell.id);
41
+
42
+ const ps = Bun.spawn(['ps', '-o', 'pid=,tpgid=', '-p', shells.map((s) => s.pid).join(',')], {
43
+ stdout: 'pipe',
44
+ stderr: 'ignore',
45
+ });
46
+
47
+ let output = '';
48
+ try {
49
+ output = await Promise.race([
50
+ new Response(ps.stdout).text(),
51
+ Bun.sleep(PS_TIMEOUT_MS).then(() => ''),
52
+ ]);
53
+ } catch {
54
+ // The pipe died; fall through to the kill and the empty result.
55
+ }
56
+ try {
57
+ ps.kill('SIGKILL');
58
+ } catch {
59
+ // Already exited.
60
+ }
61
+ void ps.exited.catch(() => {});
62
+
63
+ for (const line of output.split('\n')) {
64
+ const [pidText, tpgidText] = line.trim().split(/\s+/);
65
+ const pid = Number(pidText);
66
+ const tpgid = Number(tpgidText);
67
+ if (!Number.isFinite(pid) || !Number.isFinite(tpgid)) continue;
68
+ const id = byPid.get(pid);
69
+ // The shell holds the terminal exactly when it is its own foreground group.
70
+ if (id !== undefined && tpgid !== pid) busy.add(id);
71
+ }
72
+ return busy;
73
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { createTerminalHistory, sanitizeReplayChunk, trimUtf8ToBytes } from '@/server/lib/terminal/history';
9
+
10
+ const decode = (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8');
11
+ const encode = (text: string) => new Uint8Array(Buffer.from(text, 'utf8'));
12
+
13
+ describe('trimUtf8ToBytes', () => {
14
+ test('keeps a short buffer whole', () => {
15
+ const bytes = encode('hello');
16
+ expect(trimUtf8ToBytes(bytes, 16)).toBe(bytes);
17
+ });
18
+
19
+ test('never starts inside a multi-byte character', () => {
20
+ // 'é' is 2 bytes, '😀' is 4 — a naive slice at the boundary would leave
21
+ // continuation bytes at the head and decode the whole tail as U+FFFD.
22
+ const bytes = encode('ab😀cd');
23
+ // 8 bytes cut to 5 lands inside the emoji: step forward past it.
24
+ expect(decode(trimUtf8ToBytes(bytes, 5))).toBe('cd');
25
+ // Cut to 6 lands exactly on its first byte: the emoji is kept whole.
26
+ expect(decode(trimUtf8ToBytes(bytes, 6))).toBe('😀cd');
27
+ });
28
+
29
+ test('drops the cut character instead of emitting replacement bytes', () => {
30
+ const bytes = encode('😀');
31
+ const trimmed = trimUtf8ToBytes(bytes, 1);
32
+ expect(decode(trimmed)).toBe('');
33
+ });
34
+ });
35
+
36
+ describe('sanitizeReplayChunk', () => {
37
+ test('keeps ordinary output byte-for-byte', () => {
38
+ const input = encode('\x1b[31mred\x1b[0m plain\r\n');
39
+ const { kept, rest } = sanitizeReplayChunk(input);
40
+ expect(kept).toBe(input);
41
+ expect(rest.length).toBe(0);
42
+ });
43
+
44
+ test('drops device-attribute and cursor-position queries', () => {
45
+ const { kept, rest } = sanitizeReplayChunk(encode('a\x1b[c b\x1b[6n c'));
46
+ expect(decode(kept)).toBe('a b c');
47
+ expect(rest.length).toBe(0);
48
+ });
49
+
50
+ test('drops color queries in both BEL and ST terminated forms', () => {
51
+ const { kept } = sanitizeReplayChunk(encode('x\x1b]11;?\x07y\x1b]10;?\x1b\\z'));
52
+ expect(decode(kept)).toBe('xyz');
53
+ });
54
+
55
+ test('keeps OSC payloads that are not queries', () => {
56
+ // The window-title sequence a shell emits on every prompt is not a query.
57
+ const input = encode('\x1b]7;file://host/tmp\x07');
58
+ expect(sanitizeReplayChunk(input).kept).toBe(input);
59
+ });
60
+
61
+ test('drops a query whose bytes straddle two chunks', () => {
62
+ // The split is what makes this stateful: neither chunk alone is a complete
63
+ // query, but their concatenation is one.
64
+ const history = createTerminalHistory();
65
+ history.append(encode('abc\x1b['));
66
+ history.append(encode('c done'));
67
+ expect(decode(history.replay())).toBe('abc done');
68
+ });
69
+
70
+ test('does not hold back a sequence it can see is not a query', () => {
71
+ const { kept, rest } = sanitizeReplayChunk(encode('a\x1b[32m'));
72
+ expect(decode(kept)).toBe('a\x1b[32m');
73
+ expect(rest.length).toBe(0);
74
+ });
75
+ });
76
+
77
+ describe('createTerminalHistory', () => {
78
+ test('accumulates appended chunks in order', () => {
79
+ const history = createTerminalHistory();
80
+ history.append(encode('one '));
81
+ history.append(encode('two'));
82
+ expect(decode(history.replay())).toBe('one two');
83
+ });
84
+
85
+ test('trims from the front once the cap is exceeded', () => {
86
+ const history = createTerminalHistory(16);
87
+ history.append(encode('aaaaaaaaaa'));
88
+ history.append(encode('bbbbbbbbbb'));
89
+ // Whole chunks are dropped, so the retained size lands just under the cap
90
+ // instead of being re-sliced on every append.
91
+ expect(decode(history.replay())).toBe('bbbbbbbbbb');
92
+ });
93
+
94
+ test('trims a single oversized chunk at a UTF-8 boundary', () => {
95
+ const history = createTerminalHistory(5);
96
+ history.append(encode('ab😀cd'));
97
+ // A byte-wise cut at offset 3 would land inside the emoji and decode the
98
+ // rest as U+FFFD; the trim steps forward to the next sequence start.
99
+ expect(decode(history.replay())).toBe('cd');
100
+ });
101
+
102
+ test('keeps the newest chunks when several are dropped', () => {
103
+ const history = createTerminalHistory(7);
104
+ history.append(encode('ab'));
105
+ history.append(encode('cd'));
106
+ history.append(encode('ef'));
107
+ history.append(encode('gh'));
108
+ // 8 bytes against a 7-byte cap drops the oldest chunk whole.
109
+ expect(decode(history.replay())).toBe('cdefgh');
110
+ });
111
+
112
+ test('clear empties the replay buffer', () => {
113
+ const history = createTerminalHistory();
114
+ history.append(encode('gone'));
115
+ history.clear();
116
+ expect(history.replay().length).toBe(0);
117
+ });
118
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Server-side scrollback for a PTY session, plus the replay sanitizer.
8
+ *
9
+ * Two failure modes this module exists to prevent, both silent when wrong:
10
+ *
11
+ * 1. Trimming must be UTF-8-safe. A byte slice at the cap boundary can land
12
+ * inside a multi-byte sequence, and every byte after it decodes as U+FFFD —
13
+ * the whole tail of the scrollback turns into replacement characters.
14
+ * 2. A replayed buffer must not contain terminal *queries*. A live client
15
+ * answers `CSI c` (device attributes), `CSI 6n` (cursor position) and
16
+ * `OSC 10/11;?` (colors) by writing the reply into the PTY, so replaying a
17
+ * query makes those bytes arrive at the shell prompt as stray input. Live
18
+ * output is byte-for-byte untouched; only the replay copy is filtered.
19
+ *
20
+ * Filtering is streaming: a control sequence split across two PTY reads is
21
+ * carried into the next chunk instead of being emitted half-parsed.
22
+ */
23
+
24
+ const MAX_HISTORY_BYTES = 512 * 1024;
25
+
26
+ /** Longest partial sequence held back before it is flushed as ordinary data. */
27
+ const MAX_PENDING_BYTES = 128;
28
+
29
+ const ESC = 0x1b;
30
+ const BEL = 0x07;
31
+ const CSI = 0x5b; // [
32
+ const OSC = 0x5d; // ]
33
+ /** DCS / SOS / PM / APC — string sequences terminated by ST only. */
34
+ const STRING_INTROS: Record<number, true> = { 0x50: true, 0x58: true, 0x5e: true, 0x5f: true };
35
+
36
+ const EMPTY = new Uint8Array(0);
37
+
38
+ export interface TerminalHistory {
39
+ append(chunk: Uint8Array): void;
40
+ /** The replay buffer. Views into it stay valid until the next `append`. */
41
+ replay(): Uint8Array;
42
+ clear(): void;
43
+ }
44
+
45
+ export function createTerminalHistory(maxBytes = MAX_HISTORY_BYTES): TerminalHistory {
46
+ let chunks: Uint8Array[] = [];
47
+ let total = 0;
48
+ let pending: Uint8Array = EMPTY;
49
+
50
+ return {
51
+ append(chunk) {
52
+ if (chunk.length === 0) return;
53
+ const input = pending.length === 0 ? chunk : joinParts([pending, chunk]);
54
+ const { kept, rest } = sanitizeReplayChunk(input);
55
+ pending = rest;
56
+ if (kept.length > 0) {
57
+ chunks.push(kept);
58
+ total += kept.length;
59
+ }
60
+ // Drop whole chunks first: re-slicing the buffer on every append would
61
+ // copy up to 512 KiB per PTY read, which a chatty program turns into a
62
+ // memcpy storm. Only the boundary chunk is trimmed byte-wise.
63
+ while (total > maxBytes && chunks.length > 1) {
64
+ total -= chunks[0].length;
65
+ chunks.shift();
66
+ }
67
+ if (total > maxBytes && chunks.length === 1) {
68
+ chunks[0] = trimUtf8ToBytes(chunks[0], maxBytes);
69
+ total = chunks[0].length;
70
+ }
71
+ },
72
+ replay() {
73
+ if (chunks.length === 0) return EMPTY;
74
+ return chunks.length === 1 ? chunks[0] : joinParts(chunks);
75
+ },
76
+ clear() {
77
+ chunks = [];
78
+ total = 0;
79
+ pending = EMPTY;
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Keep the last `maxBytes`, never starting inside a multi-byte sequence. */
85
+ export function trimUtf8ToBytes(bytes: Uint8Array, maxBytes: number): Uint8Array {
86
+ if (bytes.length <= maxBytes) return bytes;
87
+ let start = bytes.length - maxBytes;
88
+ // 10xxxxxx is a continuation byte: step forward to the next sequence start.
89
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start += 1;
90
+ return bytes.subarray(start);
91
+ }
92
+
93
+ /**
94
+ * Split `input` into the bytes that may be replayed and the trailing fragment
95
+ * of an unfinished control sequence. Dropped sequences are queries only.
96
+ */
97
+ export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest: Uint8Array } {
98
+ const parts: Uint8Array[] = [];
99
+ let from = 0;
100
+ let i = 0;
101
+ let dropped = false;
102
+
103
+ while (i < input.length) {
104
+ if (input[i] !== ESC) {
105
+ i += 1;
106
+ continue;
107
+ }
108
+ const scan = scanEscape(input, i);
109
+ if (scan === null) {
110
+ if (i > from) parts.push(input.subarray(from, i));
111
+ const rest = input.subarray(i);
112
+ // A program that opened a string sequence and never closed it must not
113
+ // swallow the rest of the scrollback: emit the fragment and move on.
114
+ if (rest.length > MAX_PENDING_BYTES) {
115
+ parts.push(rest);
116
+ return { kept: joinParts(parts), rest: EMPTY };
117
+ }
118
+ return { kept: joinParts(parts), rest };
119
+ }
120
+ if (scan.drop) {
121
+ if (i > from) parts.push(input.subarray(from, i));
122
+ from = scan.end;
123
+ dropped = true;
124
+ }
125
+ i = scan.end;
126
+ }
127
+
128
+ if (!dropped) return { kept: input, rest: EMPTY };
129
+ if (from < input.length) parts.push(input.subarray(from));
130
+ return { kept: joinParts(parts), rest: EMPTY };
131
+ }
132
+
133
+ interface EscapeScan {
134
+ /** Index just past the sequence. */
135
+ end: number;
136
+ /** True when the sequence is a query the replay must not contain. */
137
+ drop: boolean;
138
+ }
139
+
140
+ /** Null means the sequence is not complete within `input`. */
141
+ function scanEscape(input: Uint8Array, start: number): EscapeScan | null {
142
+ const kind = input[start + 1];
143
+ if (kind === undefined) return null;
144
+ if (kind === CSI) return scanCsi(input, start);
145
+ if (kind === OSC) return scanOsc(input, start);
146
+ if (STRING_INTROS[kind] === true) return scanString(input, start);
147
+ // Every other escape is two bytes (ESC c, ESC 7, ESC M, …). Emitting the
148
+ // pair leaves the remaining bytes identical to the input either way.
149
+ return { end: start + 2, drop: false };
150
+ }
151
+
152
+ function scanCsi(input: Uint8Array, start: number): EscapeScan | null {
153
+ for (let i = start + 2; i < input.length; i += 1) {
154
+ const byte = input[i];
155
+ // 0x40–0x7e is the final byte; params/intermediates are below it.
156
+ if (byte >= 0x40 && byte <= 0x7e) {
157
+ return { end: i + 1, drop: isCsiQuery(input, start + 2, i) };
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function isCsiQuery(input: Uint8Array, from: number, final: number): boolean {
164
+ const finalByte = input[final];
165
+ if (finalByte === 0x63) return true; // 'c' — DA1/DA2 device-attributes request
166
+ const params = asciiOf(input, from, final);
167
+ if (finalByte === 0x6e) return /^\??[56]$/.test(params); // 'n' — DSR / cursor position
168
+ if (finalByte === 0x70) return params.includes('$') && params.includes('2031'); // DECRQM mode query
169
+ return false;
170
+ }
171
+
172
+ function scanOsc(input: Uint8Array, start: number): EscapeScan | null {
173
+ for (let i = start + 2; i < input.length; i += 1) {
174
+ const byte = input[i];
175
+ if (byte === BEL) return { end: i + 1, drop: isOscQuery(input, start + 2, i) };
176
+ if (byte === ESC && input[i + 1] === 0x5c) return { end: i + 2, drop: isOscQuery(input, start + 2, i) };
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /** Color queries: `OSC 10;?`, `OSC 11;?`, `OSC 12;?` and palette `OSC 4;n;?`. */
182
+ function isOscQuery(input: Uint8Array, from: number, to: number): boolean {
183
+ const body = asciiOf(input, from, to);
184
+ const fields = body.split(';');
185
+ if (!/^(4|10|11|12)$/.test(fields[0])) return false;
186
+ return fields.length > 1 && fields[fields.length - 1] === '?';
187
+ }
188
+
189
+ function scanString(input: Uint8Array, start: number): EscapeScan | null {
190
+ for (let i = start + 2; i < input.length; i += 1) {
191
+ if (input[i] === ESC && input[i + 1] === 0x5c) return { end: i + 2, drop: false };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function asciiOf(input: Uint8Array, from: number, to: number): string {
197
+ let out = '';
198
+ for (let i = from; i < to; i += 1) out += String.fromCharCode(input[i]);
199
+ return out;
200
+ }
201
+
202
+ function joinParts(parts: Uint8Array[]): Uint8Array {
203
+ if (parts.length === 1) return parts[0];
204
+ return Buffer.concat(parts);
205
+ }