Skip to content
Source
Sign inSign up

terminal.ts

406 lines13.3 KB

1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderInstanceId } from "./providerInstance.ts";
4​
5export const DEFAULT_TERMINAL_ID = "term-1";
6export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
7​
8const historyEncoder = new TextEncoder();
9// A BOM at a retained chunk boundary is terminal data, not an encoding marker.
10const historyDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
11​
12/** The final `maxBytes` of UTF-8 a client retains, starting at a code point boundary. */
13export function retainedTerminalHistory(history: string, maxBytes: number): string {
14 if (maxBytes <= 0) {
15 return "";
16 }
17​
18 const encoded = historyEncoder.encode(history);
19 if (encoded.byteLength <= maxBytes) {
20 return history;
21 }
22​
23 let start = encoded.byteLength - maxBytes;
24 while (start < encoded.length) {
25 const byte = encoded[start];
26 if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) {
27 break;
28 }
29 start += 1;
30 }
31​
32 return historyDecoder.decode(encoded.subarray(start));
34​
35const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
36const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
37 Schema.isLessThanOrEqualTo(1000),
38);
39const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
40 Schema.isLessThanOrEqualTo(500),
41);
42const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
43const TerminalEnvKeySchema = Schema.String.check(
44 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
45).check(Schema.isMaxLength(128));
46const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
47const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
48 Schema.isMaxProperties(128),
49);
50​
51export const TerminalThreadInput = Schema.Struct({
52 threadId: TrimmedNonEmptyStringSchema,
53});
54export type TerminalThreadInput = typeof TerminalThreadInput.Type;
55​
56// Terminal ids are client-chosen; the server never allocates them.
57const TerminalSessionInput = Schema.Struct({
58 ...TerminalThreadInput.fields,
59 terminalId: TerminalIdSchema,
60});
61export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
62​
63export const TerminalOpenInput = Schema.Struct({
64 ...TerminalSessionInput.fields,
65 cwd: TrimmedNonEmptyStringSchema,
66 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
67 cols: Schema.optional(TerminalColsSchema),
68 rows: Schema.optional(TerminalRowsSchema),
69 env: Schema.optional(TerminalEnvSchema),
70 providerInstanceId: Schema.optional(ProviderInstanceId),
71 stopOnSettle: Schema.optional(Schema.Boolean),
72});
73export type TerminalOpenInput = typeof TerminalOpenInput.Type;
74​
75export const TerminalAttachInput = Schema.Struct({
76 ...TerminalSessionInput.fields,
77 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
78 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
79 cols: Schema.optional(TerminalColsSchema),
80 rows: Schema.optional(TerminalRowsSchema),
81 env: Schema.optional(TerminalEnvSchema),
82 providerInstanceId: Schema.optional(ProviderInstanceId),
83 stopOnSettle: Schema.optional(Schema.Boolean),
84 restartIfNotRunning: Schema.optional(Schema.Boolean),
85 // Resubscriptions preserve stopped terminals; unknown IDs recover after a server restart.
86 resume: Schema.optional(Schema.Boolean),
87 // Snapshots carry only the history a client this bounded keeps; older servers ignore it.
88 maxHistoryBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
89});
90export type TerminalAttachInput = typeof TerminalAttachInput.Type;
91​
92export const TerminalObserveInput = TerminalSessionInput;
93export type TerminalObserveInput = Schema.Codec.Encoded<typeof TerminalObserveInput>;
94​
95export const TerminalWriteInput = Schema.Struct({
96 ...TerminalSessionInput.fields,
97 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
98});
99export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>;
100​
101export const TerminalResizeInput = Schema.Struct({
102 ...TerminalSessionInput.fields,
103 cols: TerminalColsSchema,
104 rows: TerminalRowsSchema,
105});
106export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>;
107​
108export const TerminalClearInput = TerminalSessionInput;
109export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>;
110​
111export const TerminalCloseInput = Schema.Struct({
112 ...TerminalThreadInput.fields,
113 terminalId: Schema.optional(TerminalIdSchema),
114 deleteHistory: Schema.optional(Schema.Boolean),
115});
116export type TerminalCloseInput = typeof TerminalCloseInput.Type;
117​
118export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
119export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
120​
121export const TerminalSessionSnapshot = Schema.Struct({
122 threadId: Schema.String.check(Schema.isNonEmpty()),
123 terminalId: Schema.String.check(Schema.isNonEmpty()),
124 cwd: Schema.String.check(Schema.isNonEmpty()),
125 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
126 status: TerminalSessionStatus,
127 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
128 history: Schema.String,
129 exitCode: Schema.NullOr(Schema.Int),
130 exitSignal: Schema.NullOr(Schema.Int),
131 label: Schema.String.check(Schema.isMaxLength(128)),
132 updatedAt: Schema.String,
133 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
134 // The PTY grid every renderer of this terminal should draw; absent from older servers.
135 cols: Schema.optional(TerminalColsSchema),
136 rows: Schema.optional(TerminalRowsSchema),
137});
138export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
139​
140export const TerminalSummary = Schema.Struct({
141 threadId: Schema.String.check(Schema.isNonEmpty()),
142 terminalId: Schema.String.check(Schema.isNonEmpty()),
143 cwd: Schema.String.check(Schema.isNonEmpty()),
144 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
145 status: TerminalSessionStatus,
146 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
147 exitCode: Schema.NullOr(Schema.Int),
148 exitSignal: Schema.NullOr(Schema.Int),
149 hasRunningSubprocess: Schema.Boolean,
150 label: Schema.String.check(Schema.isMaxLength(128)),
151 updatedAt: Schema.String,
152});
153export type TerminalSummary = typeof TerminalSummary.Type;
154​
155const TerminalMetadataSnapshotEvent = Schema.Struct({
156 type: Schema.Literal("snapshot"),
157 terminals: Schema.Array(TerminalSummary),
158});
159​
160const TerminalMetadataUpsertEvent = Schema.Struct({
161 type: Schema.Literal("upsert"),
162 terminal: TerminalSummary,
163});
164​
165const TerminalMetadataRemoveEvent = Schema.Struct({
166 type: Schema.Literal("remove"),
167 threadId: Schema.String.check(Schema.isNonEmpty()),
168 terminalId: Schema.String.check(Schema.isNonEmpty()),
169});
170​
171export const TerminalMetadataStreamEvent = Schema.Union([
172 TerminalMetadataSnapshotEvent,
173 TerminalMetadataUpsertEvent,
174 TerminalMetadataRemoveEvent,
175]);
176export type TerminalMetadataStreamEvent = typeof TerminalMetadataStreamEvent.Type;
177​
178const TerminalEventBaseSchema = Schema.Struct({
179 threadId: Schema.String.check(Schema.isNonEmpty()),
180 terminalId: Schema.String.check(Schema.isNonEmpty()),
181 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
182});
183​
184const TerminalStartedEvent = Schema.Struct({
185 ...TerminalEventBaseSchema.fields,
186 type: Schema.Literal("started"),
187 snapshot: TerminalSessionSnapshot,
188});
189​
190const TerminalOutputEvent = Schema.Struct({
191 ...TerminalEventBaseSchema.fields,
192 type: Schema.Literal("output"),
193 data: Schema.String,
194});
195​
196const TerminalExitedEvent = Schema.Struct({
197 ...TerminalEventBaseSchema.fields,
198 type: Schema.Literal("exited"),
199 exitCode: Schema.NullOr(Schema.Int),
200 exitSignal: Schema.NullOr(Schema.Int),
201});
202​
203const TerminalClosedEvent = Schema.Struct({
204 ...TerminalEventBaseSchema.fields,
205 type: Schema.Literal("closed"),
206});
207​
208const TerminalErrorEvent = Schema.Struct({
209 ...TerminalEventBaseSchema.fields,
210 type: Schema.Literal("error"),
211 message: Schema.String.check(Schema.isNonEmpty()),
212});
213​
214const TerminalClearedEvent = Schema.Struct({
215 ...TerminalEventBaseSchema.fields,
216 type: Schema.Literal("cleared"),
217});
218​
219const TerminalRestartedEvent = Schema.Struct({
220 ...TerminalEventBaseSchema.fields,
221 type: Schema.Literal("restarted"),
222 snapshot: TerminalSessionSnapshot,
223});
224​
225const TerminalActivityEvent = Schema.Struct({
226 ...TerminalEventBaseSchema.fields,
227 type: Schema.Literal("activity"),
228 hasRunningSubprocess: Schema.Boolean,
229 label: Schema.String.check(Schema.isMaxLength(128)),
230 // Shared PTY grid changes ride on activity because older clients reject unknown event types.
231 cols: Schema.optional(TerminalColsSchema),
232 rows: Schema.optional(TerminalRowsSchema),
233});
234​
235export const TerminalEvent = Schema.Union([
236 TerminalStartedEvent,
237 TerminalOutputEvent,
238 TerminalExitedEvent,
239 TerminalClosedEvent,
240 TerminalErrorEvent,
241 TerminalClearedEvent,
242 TerminalRestartedEvent,
243 TerminalActivityEvent,
244]);
245export type TerminalEvent = typeof TerminalEvent.Type;
246​
247const TerminalAttachSnapshotEvent = Schema.Struct({
248 type: Schema.Literal("snapshot"),
249 snapshot: TerminalSessionSnapshot,
250});
251​
252export const TerminalAttachStreamEvent = Schema.Union([
253 TerminalAttachSnapshotEvent,
254 TerminalOutputEvent,
255 TerminalExitedEvent,
256 TerminalClosedEvent,
257 TerminalErrorEvent,
258 TerminalClearedEvent,
259 TerminalRestartedEvent,
260 TerminalActivityEvent,
261]);
262export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type;
263​
264export class TerminalCwdNotFoundError extends Schema.TaggedError<TerminalCwdNotFoundError>()(
265 "TerminalCwdNotFoundError",
266 {
267 cwd: Schema.String,
268 },
269) {
270 override get message() {
271 return `Terminal folder does not exist: ${this.cwd}. Restore the folder at this path before retrying.`;
272 }
274​
275export class TerminalCwdNotDirectoryError extends Schema.TaggedError<TerminalCwdNotDirectoryError>()(
276 "TerminalCwdNotDirectoryError",
277 {
278 cwd: Schema.String,
279 },
280) {
281 override get message() {
282 return `Terminal cwd is not a directory: ${this.cwd}`;
283 }
285​
286export class TerminalCwdStatError extends Schema.TaggedError<TerminalCwdStatError>()(
287 "TerminalCwdStatError",
288 {
289 cwd: Schema.String,
290 cause: Schema.Defect(),
291 },
292) {
293 override get message() {
294 return `Failed to access terminal cwd: ${this.cwd}`;
295 }
297​
298export const TerminalCwdError = Schema.Union([
299 TerminalCwdNotFoundError,
300 TerminalCwdNotDirectoryError,
301 TerminalCwdStatError,
302]);
303export type TerminalCwdError = typeof TerminalCwdError.Type;
304​
305export class TerminalHistoryError extends Schema.TaggedError<TerminalHistoryError>()(
306 "TerminalHistoryError",
307 {
308 operation: Schema.Literals(["read", "truncate", "migrate"]),
309 threadId: Schema.String,
310 terminalId: Schema.String,
311 cause: Schema.optional(Schema.Defect()),
312 },
313) {
314 override get message() {
315 return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
316 }
318​
319export class TerminalSessionLookupError extends Schema.TaggedError<TerminalSessionLookupError>()(
320 "TerminalSessionLookupError",
321 {
322 threadId: Schema.String,
323 terminalId: Schema.String,
324 },
325) {
326 override get message() {
327 return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
328 }
330​
331export class TerminalProviderInstanceNotFoundError extends Schema.TaggedError<TerminalProviderInstanceNotFoundError>()(
332 "TerminalProviderInstanceNotFoundError",
333 {
334 providerInstanceId: ProviderInstanceId,
335 },
336) {
337 override get message() {
338 return `Provider instance is not available: ${this.providerInstanceId}`;
339 }
341​
342export class TerminalProviderEnvironmentError extends Schema.TaggedError<TerminalProviderEnvironmentError>()(
343 "TerminalProviderEnvironmentError",
344 {
345 providerInstanceId: ProviderInstanceId,
346 cause: Schema.Defect(),
347 },
348) {
349 override get message() {
350 return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`;
351 }
353​
354export class TerminalNotRunningError extends Schema.TaggedError<TerminalNotRunningError>()(
355 "TerminalNotRunningError",
356 {
357 threadId: Schema.String,
358 terminalId: Schema.String,
359 },
360) {
361 override get message() {
362 return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
363 }
365​
366export class TerminalWriteError extends Schema.TaggedError<TerminalWriteError>()(
367 "TerminalWriteError",
368 {
369 threadId: Schema.String,
370 terminalId: Schema.String,
371 terminalPid: Schema.Number,
372 cause: Schema.Defect(),
373 },
374) {
375 override get message() {
376 return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`;
377 }
379​
380export class TerminalResizeError extends Schema.TaggedError<TerminalResizeError>()(
381 "TerminalResizeError",
382 {
383 threadId: Schema.String,
384 terminalId: Schema.String,
385 terminalPid: Schema.Number,
386 cols: TerminalColsSchema,
387 rows: TerminalRowsSchema,
388 cause: Schema.Defect(),
389 },
390) {
391 override get message() {
392 return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`;
393 }
395​
396export const TerminalError = Schema.Union([
397 TerminalCwdError,
398 TerminalHistoryError,
399 TerminalSessionLookupError,
400 TerminalProviderInstanceNotFoundError,
401 TerminalProviderEnvironmentError,
402 TerminalNotRunningError,
403 TerminalWriteError,
404 TerminalResizeError,
405]);
406export type TerminalError = typeof TerminalError.Type;