ompchamber 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -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
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The terminal registry: which shells are live, who is watching them, and when
8
+ * they die.
9
+ *
10
+ * One real shell per terminal id, created on the first `attach` (there is no
11
+ * separate create endpoint to race against), kept alive while clients come and
12
+ * go, and killed through the process group so the commands it started die with
13
+ * it. Session mechanics live in `session.server.ts`; this module owns the map,
14
+ * the viewer sets, the idle reaper and process-exit cleanup.
15
+ */
16
+
17
+ import path from 'path';
18
+ import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
19
+ import {
20
+ TERMINATION_GRACE_MS,
21
+ TerminalRuntimeError,
22
+ closeTerminalStream,
23
+ createSession,
24
+ signalGroup,
25
+ snapshotOf,
26
+ type AttachRequest,
27
+ type TerminalSession,
28
+ type TerminalViewer,
29
+ } from '@/server/lib/terminal/session.server';
30
+ import { TERMINAL_MAX_INPUT_BYTES, clampTerminalSize, type TerminalSnapshot } from '@/shared/lib/workspace/terminal/protocol';
31
+
32
+ const MAX_TERMINALS = 8;
33
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
34
+ const IDLE_SWEEP_MS = 60 * 1000;
35
+ /** Quiet period before a resize burst is committed to the PTY. */
36
+ const RESIZE_SETTLE_MS = 120;
37
+
38
+ export interface TerminalAttachment {
39
+ snapshot: TerminalSnapshot;
40
+ /** Retained scrollback, to be sent after the `ready` frame. */
41
+ replay: Uint8Array;
42
+ }
43
+
44
+ /**
45
+ * The registry lives on `globalThis`, not in a module-local binding.
46
+ *
47
+ * `bun run --hot` re-evaluates this module on every server-file edit, but the
48
+ * PTY children are detached sessions that keep running. A module-local `Map`
49
+ * would be replaced by a fresh empty one on each reload, leaving the previous
50
+ * shells alive with nothing tracking them — unreachable from the client, never
51
+ * counted, never reaped, and still holding their slots. Anchoring the map to
52
+ * the process keeps a reload from orphaning anything.
53
+ */
54
+ declare global {
55
+ // eslint-disable-next-line no-var
56
+ var __ompChamberTerminals: Map<string, TerminalSession> | undefined;
57
+ // eslint-disable-next-line no-var
58
+ var __ompChamberTerminalSweep: ReturnType<typeof setInterval> | undefined;
59
+ // eslint-disable-next-line no-var
60
+ var __ompChamberTerminalResizes: Map<string, ReturnType<typeof setTimeout>> | undefined;
61
+ // eslint-disable-next-line no-var
62
+ var __ompChamberTerminalCleanupInstalled: boolean | undefined;
63
+ }
64
+
65
+ const sessions: Map<string, TerminalSession> = (globalThis.__ompChamberTerminals ??= new Map());
66
+
67
+ function getSweepTimer() {
68
+ return globalThis.__ompChamberTerminalSweep;
69
+ }
70
+
71
+ function setSweepTimer(timer: ReturnType<typeof setInterval> | undefined) {
72
+ globalThis.__ompChamberTerminalSweep = timer;
73
+ return timer;
74
+ }
75
+
76
+ function getResizeTimers(): Map<string, ReturnType<typeof setTimeout>> {
77
+ return (globalThis.__ompChamberTerminalResizes ??= new Map());
78
+ }
79
+
80
+ /**
81
+ * Attach `viewer` to `request.id`, creating the shell when the id is unknown.
82
+ *
83
+ * Registration and history capture happen in one synchronous block: Bun cannot
84
+ * interleave a PTY `data` callback inside it, so the viewer can neither miss
85
+ * output nor receive it twice.
86
+ *
87
+ * The cap counts *live* shells only. An exited shell is a corpse holding a
88
+ * scrollback nobody is reading, and letting corpses consume the budget is how
89
+ * a session that restarted a shell a few times hits the limit with nothing
90
+ * running: every restart leaves its predecessor behind.
91
+ */
92
+ export async function attachTerminal(viewer: TerminalViewer, request: AttachRequest): Promise<TerminalAttachment> {
93
+ let session = sessions.get(request.id);
94
+ if (!session) {
95
+ reclaimExitedTerminals();
96
+ if ((await countLiveTerminals()) >= MAX_TERMINALS) {
97
+ throw new TerminalRuntimeError('capacity', `Terminal limit reached (${MAX_TERMINALS} busy shells)`);
98
+ }
99
+ const created = await createSession(request);
100
+ sessions.set(request.id, created);
101
+ // An exit with nobody watching is the common case for a replaced shell
102
+ // (restart, repo switch): reap it as soon as it lands instead of leaving
103
+ // the record for the sweep, which is up to a minute of holding a slot.
104
+ void created.proc?.exited.then(() => {
105
+ if (created.viewers.size === 0) forgetTerminal(created.id);
106
+ });
107
+ ensureSweeper();
108
+ session = created;
109
+ }
110
+
111
+ const replay = session.history.replay();
112
+ const snapshot = snapshotOf(session);
113
+ session.viewers.add(viewer);
114
+ session.lastActivity = Date.now();
115
+ return { snapshot, replay };
116
+ }
117
+
118
+ /**
119
+ * Shells that are actually doing work, which is what the cap budgets.
120
+ *
121
+ * Two things are deliberately excluded: an exited shell (a corpse with a
122
+ * scrollback) and a shell sitting at its prompt — the PTY's foreground process
123
+ * group tells them apart exactly (see `foreground.ts`). Counting idle shells
124
+ * is how a panel that was restarted or switched a few times pins the budget
125
+ * with nothing running, and the next attach is refused.
126
+ */
127
+ export async function countLiveTerminals(): Promise<number> {
128
+ const running: ShellProcess[] = [];
129
+ for (const session of sessions.values()) {
130
+ if (session.status !== 'running' || !session.proc) continue;
131
+ running.push({ id: session.id, pid: session.proc.pid });
132
+ }
133
+ const busy = await detectBusyShells(running);
134
+ return busy.size;
135
+ }
136
+
137
+ /**
138
+ * Drop exited shells that nobody is watching.
139
+ *
140
+ * Run before a create, not only on the idle sweep: the sweep is a minute away,
141
+ * and a burst of restarts inside that window would otherwise be rejected while
142
+ * every corpse in the way is already dead.
143
+ */
144
+ export function reclaimExitedTerminals(): number {
145
+ let reclaimed = 0;
146
+ for (const [id, session] of sessions) {
147
+ if (session.status !== 'exited') continue;
148
+ if (session.viewers.size > 0) continue;
149
+ forgetTerminal(id);
150
+ reclaimed += 1;
151
+ }
152
+ return reclaimed;
153
+ }
154
+
155
+ export function detachTerminal(viewer: TerminalViewer, id: string): void {
156
+ const session = sessions.get(id);
157
+ if (!session) return;
158
+ session.viewers.delete(viewer);
159
+ session.lastActivity = Date.now();
160
+ // The last viewer leaving a finished shell leaves nothing behind worth
161
+ // keeping: no process to reattach to, and a scrollback nobody will read.
162
+ if (session.status === 'exited' && session.viewers.size === 0) forgetTerminal(id);
163
+ }
164
+
165
+ /**
166
+ * Live terminal summaries, optionally narrowed to one scope, so a client can
167
+ * adopt a shell it does not have an id for (a reloaded page, a second tab).
168
+ *
169
+ * `busy` marks a shell with a foreground command — the panel shows it as
170
+ * running, and the cap budgets it. One `ps` for the whole list.
171
+ */
172
+ export async function listTerminals(root?: string | null, repo?: string | null): Promise<TerminalSnapshot[]> {
173
+ const scope = root ? (repo && repo !== '.' ? path.join(root, repo) : root) : null;
174
+ const running: ShellProcess[] = [];
175
+ const out: TerminalSnapshot[] = [];
176
+ for (const session of sessions.values()) {
177
+ if (scope && !isWithin(session.cwd, scope)) continue;
178
+ if (session.status === 'running' && session.proc) {
179
+ running.push({ id: session.id, pid: session.proc.pid });
180
+ }
181
+ out.push(snapshotOf(session));
182
+ }
183
+ const busy = await detectBusyShells(running);
184
+ return out.map((snapshot) => ({ ...snapshot, busy: busy.has(snapshot.id) }));
185
+ }
186
+
187
+ function isWithin(target: string, root: string): boolean {
188
+ return target === root || target.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
189
+ }
190
+
191
+ export function writeTerminalInput(id: string, data: Uint8Array): void {
192
+ const session = sessions.get(id);
193
+ if (!session || session.status !== 'running') return;
194
+ if (data.length === 0 || data.length > TERMINAL_MAX_INPUT_BYTES) return;
195
+ session.lastActivity = Date.now();
196
+ try {
197
+ session.term.write(data);
198
+ } catch {
199
+ // The PTY closed between the status check and the write.
200
+ }
201
+ }
202
+
203
+ export function resizeTerminal(id: string, cols: number, rows: number): void {
204
+ const session = sessions.get(id);
205
+ if (!session || session.status !== 'running') return;
206
+ const size = clampTerminalSize(cols, rows);
207
+ if (size.cols === session.cols && size.rows === session.rows) return;
208
+ session.cols = size.cols;
209
+ session.rows = size.rows;
210
+ session.lastActivity = Date.now();
211
+ try {
212
+ session.term.resize(size.cols, size.rows);
213
+ } catch {
214
+ return;
215
+ }
216
+ // resize() updates the winsize but never signals the child, so a TUI would
217
+ // not repaint. Deliver SIGWINCH ourselves.
218
+ signalGroup(session, 'SIGWINCH');
219
+ }
220
+
221
+ /**
222
+ * Debounce for `resizeTerminal`.
223
+ *
224
+ * A panel drag produces a burst of sizes — the pointer moves in steps and
225
+ * xterm's FitAddon refits on every animation frame — and each accepted size
226
+ * sends SIGWINCH. The shell answers every one of them by redrawing its prompt,
227
+ * so a single drag used to print the prompt a dozen times (measured: 10
228
+ * SIGWINCH in one second for one drag).
229
+ *
230
+ * Only the trailing size matters: the PTY ends at the final grid either way,
231
+ * and a repaint of an intermediate size is invisible work. A single timeout is
232
+ * reused so the burst coalesces into one signal.
233
+ */
234
+ const resizeTimers = getResizeTimers();
235
+
236
+ export function resizeTerminalDebounced(id: string, cols: number, rows: number): void {
237
+ const pending = resizeTimers.get(id);
238
+ if (pending) clearTimeout(pending);
239
+ resizeTimers.set(
240
+ id,
241
+ setTimeout(() => {
242
+ resizeTimers.delete(id);
243
+ resizeTerminal(id, cols, rows);
244
+ }, RESIZE_SETTLE_MS),
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Kill the shell for `id`, keeping the record and its viewers: the process exit
250
+ * is what reports the real exit code, so nothing is synthesized here.
251
+ *
252
+ * SIGHUP, not SIGTERM. An interactive shell ignores SIGTERM — verified on
253
+ * macOS: `kill -TERM` to a zsh process group left it alive indefinitely, while
254
+ * SIGHUP terminated it in ~150 ms along with its background jobs. SIGHUP is
255
+ * also the semantically right signal: the terminal is going away.
256
+ */
257
+ export function closeTerminal(id: string): void {
258
+ const session = sessions.get(id);
259
+ if (!session || session.status !== 'running') return;
260
+ signalGroup(session, 'SIGHUP');
261
+ setTimeout(() => {
262
+ if (session.status === 'running') signalGroup(session, 'SIGKILL');
263
+ }, TERMINATION_GRACE_MS).unref?.();
264
+ }
265
+
266
+ /**
267
+ * Drop a session record and release its PTY stream. Used by the idle reaper and
268
+ * when a client closes a terminal it does not intend to reopen — there are no
269
+ * viewers left to inform, so a running shell is killed outright.
270
+ */
271
+ export function forgetTerminal(id: string): void {
272
+ const session = sessions.get(id);
273
+ if (!session) return;
274
+ sessions.delete(id);
275
+ clearPendingResize(id);
276
+ session.viewers.clear();
277
+ if (session.status === 'running') signalGroup(session, 'SIGKILL');
278
+ closeTerminalStream(session);
279
+ }
280
+
281
+ function clearPendingResize(id: string): void {
282
+ const pending = resizeTimers.get(id);
283
+ if (pending) {
284
+ clearTimeout(pending);
285
+ resizeTimers.delete(id);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Kill every shell at process exit. Synchronous by contract — `exit` handlers
291
+ * cannot await — so it escalates straight to SIGKILL: a detached shell is its
292
+ * own session and would otherwise outlive the server.
293
+ */
294
+ export function disposeAllTerminals(): void {
295
+ clearInterval(setSweepTimer(undefined));
296
+ for (const [id, session] of sessions) {
297
+ clearPendingResize(id);
298
+ session.viewers.clear();
299
+ signalGroup(session, 'SIGKILL');
300
+ closeTerminalStream(session);
301
+ }
302
+ sessions.clear();
303
+ }
304
+
305
+ function ensureSweeper(): void {
306
+ if (getSweepTimer() !== undefined) return;
307
+ setSweepTimer(setInterval(() => {
308
+ // Exited shells are reaped on exit and on detach; this catches the one
309
+ // that finished while a viewer was still attached to it.
310
+ reclaimExitedTerminals();
311
+ const now = Date.now();
312
+ for (const [id, session] of sessions) {
313
+ // A live shell is only reaped when nobody watches it: an attached
314
+ // terminal is idle by definition while the user reads its output.
315
+ if (session.viewers.size > 0) continue;
316
+ if (now - session.lastActivity < IDLE_TIMEOUT_MS) continue;
317
+ forgetTerminal(id);
318
+ }
319
+ }, IDLE_SWEEP_MS));
320
+ getSweepTimer()!.unref();
321
+ }
322
+
323
+ /**
324
+ * Register process-wide cleanup exactly once. A `bun --hot` reload re-evaluates
325
+ * this module while the shells live on, so the guard keeps a reload from
326
+ * stacking duplicate signal handlers.
327
+ */
328
+ declare global {
329
+ // eslint-disable-next-line no-var
330
+ var __ompChamberTerminalCleanupInstalled: boolean | undefined;
331
+ }
332
+
333
+ if (!globalThis.__ompChamberTerminalCleanupInstalled) {
334
+ globalThis.__ompChamberTerminalCleanupInstalled = true;
335
+ process.once('exit', disposeAllTerminals);
336
+ process.once('SIGINT', disposeAllTerminals);
337
+ process.once('SIGTERM', disposeAllTerminals);
338
+ }