@treeport/treeport 0.6.1 → 0.7.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.
@@ -0,0 +1,962 @@
1
+ import { an as parseTerminalProgress } from "../../dist-BsLn2Gbc.js";
2
+ import { i as terminalHostRecordSchema, n as encodeTerminalHostFrame, o as makeHostTraceRuntime, r as terminalHostInputSchemas, t as TerminalHostFrameDecoder } from "../../terminal-host-protocol-DZkQRAUF.js";
3
+ import { n as prepareShellIntegration, t as integrateShellLaunch } from "../../shell-integration-CPmrVa3B.js";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { execFile } from "node:child_process";
7
+ import crypto from "node:crypto";
8
+ import { promisify } from "node:util";
9
+ import net from "node:net";
10
+ import { SerializeAddon } from "@xterm/addon-serialize";
11
+ import xtermHeadless from "@xterm/headless";
12
+ import * as pty from "node-pty";
13
+ //#region src/server/core/terminal.ts
14
+ const TERMINAL_PROGRESS_STALE_MS = 5 * 6e4;
15
+ //#endregion
16
+ //#region src/server/terminal-host-sessions.ts
17
+ const { Terminal } = xtermHeadless;
18
+ const HOST_SCROLLBACK_LINES = 5e4;
19
+ const HOST_PARSER_HIGH_WATERMARK = 1024 * 1024;
20
+ const HOST_PARSER_LOW_WATERMARK = 256 * 1024;
21
+ const PROCESS_TREE_KILL_GRACE_MS = 500;
22
+ const execute = promisify(execFile);
23
+ async function descendantPids(rootPid) {
24
+ const rows = await execute("ps", ["-axo", "pid=,ppid="]).then(({ stdout }) => stdout.split("\n").map((line) => line.trim().split(/\s+/u).map(Number)).filter((row) => row.length === 2 && row.every(Number.isInteger))).catch(() => []);
25
+ const children = /* @__PURE__ */ new Map();
26
+ for (const [pid, parentPid] of rows) children.set(parentPid, [...children.get(parentPid) ?? [], pid]);
27
+ const descendants = [];
28
+ const pending = [...children.get(rootPid) ?? []];
29
+ while (pending.length) {
30
+ const pid = pending.pop();
31
+ descendants.push(pid);
32
+ pending.push(...children.get(pid) ?? []);
33
+ }
34
+ return descendants;
35
+ }
36
+ function signalPid(pid, signal) {
37
+ try {
38
+ process.kill(pid, signal);
39
+ return true;
40
+ } catch (error) {
41
+ if (error.code === "ESRCH") return false;
42
+ throw error;
43
+ }
44
+ }
45
+ async function terminatePtyProcessTree(child) {
46
+ const descendants = await descendantPids(child.pid);
47
+ const signalTree = (signal) => {
48
+ const groupSignaled = signalPid(-child.pid, signal);
49
+ for (const pid of [...descendants].reverse()) signalPid(pid, signal);
50
+ if (!groupSignaled) child.kill(signal);
51
+ };
52
+ signalTree("SIGTERM");
53
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_TREE_KILL_GRACE_MS));
54
+ signalTree("SIGKILL");
55
+ }
56
+ /**
57
+ * Detached owner of one PTY and one canonical emulator per terminal.
58
+ *
59
+ * The headless emulator answers terminal queries while no browser controller
60
+ * owns that authority. Authority changes use a paused parser boundary so two
61
+ * emulators never answer the same query.
62
+ */
63
+ var TerminalHostSessionManager = class {
64
+ runtimeDir;
65
+ launcherPath;
66
+ spawnPty;
67
+ terminateProcessTree;
68
+ progressStaleMs;
69
+ shellIntegrationDir;
70
+ sessions = /* @__PURE__ */ new Map();
71
+ pendingCleanups = /* @__PURE__ */ new Set();
72
+ initialization = null;
73
+ constructor(runtimeDir, launcherPath, spawnPty = pty.spawn, terminateProcessTree = terminatePtyProcessTree, progressStaleMs = TERMINAL_PROGRESS_STALE_MS) {
74
+ this.runtimeDir = runtimeDir;
75
+ this.launcherPath = launcherPath;
76
+ this.spawnPty = spawnPty;
77
+ this.terminateProcessTree = terminateProcessTree;
78
+ this.progressStaleMs = progressStaleMs;
79
+ this.shellIntegrationDir = path.join(runtimeDir, "terminal-shell-integration");
80
+ }
81
+ initialize() {
82
+ this.initialization ??= Promise.all([fs.mkdir(path.join(this.runtimeDir, "terminal-specs"), {
83
+ recursive: true,
84
+ mode: 448
85
+ }), prepareShellIntegration(this.shellIntegrationDir)]).then(() => void 0);
86
+ return this.initialization.then(() => true);
87
+ }
88
+ async createTerminal(input) {
89
+ await this.initialize();
90
+ if (this.sessions.has(input.terminalId)) throw new Error(`Terminal already exists: ${input.terminalId}`);
91
+ const directShell = input.interactiveShell && input.shellCommand === null && !input.initialTitle && !input.fallbackArgv && !input.setupTasks?.length && !input.setupError;
92
+ let specPath = null;
93
+ if (!directShell) {
94
+ const spec = {
95
+ argv: [...input.argv],
96
+ cwd: input.cwd,
97
+ env: { ...input.env },
98
+ shellIntegrationDir: this.shellIntegrationDir
99
+ };
100
+ if (input.initialTitle) spec.initialTitle = input.initialTitle;
101
+ if (input.fallbackArgv) spec.fallbackArgv = [...input.fallbackArgv];
102
+ if (input.setupTasks) spec.setupTasks = input.setupTasks;
103
+ if (input.setupError) spec.setupError = input.setupError;
104
+ specPath = path.join(this.runtimeDir, "terminal-specs", `${input.terminalId}-${crypto.randomUUID()}.json`);
105
+ await fs.writeFile(specPath, JSON.stringify(spec), { mode: 384 });
106
+ }
107
+ const size = input.initialSize ?? {
108
+ cols: 100,
109
+ rows: 30
110
+ };
111
+ const inheritedEnvironment = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
112
+ inheritedEnvironment.TERM = "xterm-256color";
113
+ const directLaunch = integrateShellLaunch(input.argv, {
114
+ ...inheritedEnvironment,
115
+ ...input.env
116
+ }, this.shellIntegrationDir, directShell);
117
+ const directEnvironment = Object.fromEntries(Object.entries(directLaunch.env).filter((entry) => entry[1] !== void 0));
118
+ const terminal = new Terminal({
119
+ cols: size.cols,
120
+ rows: size.rows,
121
+ scrollback: HOST_SCROLLBACK_LINES,
122
+ allowProposedApi: true,
123
+ disableStdin: false
124
+ });
125
+ const serializer = new SerializeAddon();
126
+ terminal.loadAddon(serializer);
127
+ let child;
128
+ try {
129
+ child = directShell ? this.spawnPty(directLaunch.argv[0], directLaunch.argv.slice(1), {
130
+ name: "xterm-256color",
131
+ cols: size.cols,
132
+ rows: size.rows,
133
+ cwd: input.cwd,
134
+ env: directEnvironment
135
+ }) : this.spawnPty(process.execPath, [this.launcherPath, specPath], {
136
+ name: "xterm-256color",
137
+ cols: size.cols,
138
+ rows: size.rows,
139
+ cwd: input.cwd,
140
+ env: inheritedEnvironment
141
+ });
142
+ } catch (error) {
143
+ serializer.dispose();
144
+ terminal.dispose();
145
+ if (specPath) await fs.rm(specPath, { force: true });
146
+ throw error;
147
+ }
148
+ const outputListeners = /* @__PURE__ */ new Set();
149
+ const runtimeListeners = /* @__PURE__ */ new Set();
150
+ const session = {
151
+ id: input.terminalId,
152
+ worktreeId: input.worktreeId,
153
+ name: input.name,
154
+ argv: [...input.argv],
155
+ shellCommand: input.shellCommand,
156
+ interactiveShell: input.interactiveShell,
157
+ closeOnSuccess: input.closeOnSuccess ?? false,
158
+ status: "running",
159
+ exitCode: null,
160
+ createdAt: input.createdAt,
161
+ updatedAt: input.createdAt,
162
+ cwd: input.cwd,
163
+ specPath,
164
+ title: input.initialTitle ?? null,
165
+ commandLine: input.initialTitle ?? null,
166
+ progress: null,
167
+ progressLease: null,
168
+ bellSequence: 0,
169
+ lastBellAt: null,
170
+ pty: child,
171
+ terminal,
172
+ serializer,
173
+ outputSequence: 0,
174
+ parserQueue: [],
175
+ parserQueuedBytes: 0,
176
+ parserWriting: false,
177
+ parserPaused: false,
178
+ parserWaiters: /* @__PURE__ */ new Set(),
179
+ boundaryPauseCount: 0,
180
+ queryAuthorityAttachmentId: null,
181
+ queryAuthorityGeneration: null,
182
+ queryTransitionId: null,
183
+ outputListeners,
184
+ runtimeListeners,
185
+ dataDisposable: null,
186
+ exitDisposable: null
187
+ };
188
+ terminal.onData((data) => {
189
+ if (session.status === "running" && session.queryAuthorityAttachmentId === null) session.pty.write(data);
190
+ });
191
+ terminal.onTitleChange((title) => {
192
+ session.title = title;
193
+ for (const listener of runtimeListeners) listener({
194
+ title,
195
+ titleState: {
196
+ terminalTitle: title,
197
+ currentCommand: session.pty.process || null,
198
+ commandLine: session.commandLine
199
+ }
200
+ });
201
+ });
202
+ terminal.onBell(() => {
203
+ session.bellSequence += 1;
204
+ session.lastBellAt = (/* @__PURE__ */ new Date()).toISOString();
205
+ for (const listener of runtimeListeners) listener({ bell: {
206
+ sequence: session.bellSequence,
207
+ at: session.lastBellAt
208
+ } });
209
+ });
210
+ terminal.parser.registerOscHandler(777, (payload) => {
211
+ if (!payload.startsWith("command;")) return false;
212
+ session.commandLine = payload.slice(8).replace(/\p{Cc}/gu, "").trim().slice(0, 256) || null;
213
+ const titleState = {
214
+ terminalTitle: session.title,
215
+ currentCommand: session.pty.process || null,
216
+ commandLine: session.commandLine
217
+ };
218
+ for (const listener of runtimeListeners) listener({ titleState });
219
+ return true;
220
+ });
221
+ terminal.parser.registerOscHandler(9, (payload) => {
222
+ const progress = parseTerminalProgress(payload);
223
+ if (progress === void 0) return false;
224
+ if (session.progressLease) {
225
+ clearTimeout(session.progressLease);
226
+ session.progressLease = null;
227
+ }
228
+ session.progress = progress;
229
+ if (progress !== null) {
230
+ session.progressLease = setTimeout(() => {
231
+ session.progressLease = null;
232
+ if (this.sessions.get(session.id) !== session) return;
233
+ session.progress = null;
234
+ for (const listener of runtimeListeners) listener({ progress: null });
235
+ }, this.progressStaleMs);
236
+ session.progressLease.unref();
237
+ }
238
+ for (const listener of runtimeListeners) listener({ progress });
239
+ return true;
240
+ });
241
+ this.sessions.set(input.terminalId, session);
242
+ session.dataDisposable = child.onData((data) => {
243
+ const bytes = Buffer.byteLength(data);
244
+ session.parserQueue.push({
245
+ data,
246
+ sequence: ++session.outputSequence,
247
+ bytes
248
+ });
249
+ session.parserQueuedBytes += bytes;
250
+ if (!session.parserPaused && session.parserQueuedBytes >= HOST_PARSER_HIGH_WATERMARK) {
251
+ session.parserPaused = true;
252
+ session.pty.pause();
253
+ }
254
+ this.parseNext(session);
255
+ });
256
+ session.exitDisposable = child.onExit(({ exitCode }) => {
257
+ if (session.progressLease) {
258
+ clearTimeout(session.progressLease);
259
+ session.progressLease = null;
260
+ }
261
+ session.progress = null;
262
+ session.status = "exited";
263
+ for (const resolve of session.parserWaiters) resolve();
264
+ session.parserWaiters.clear();
265
+ session.exitCode = exitCode;
266
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
267
+ for (const listener of [...runtimeListeners]) listener({ exitCode });
268
+ if (specPath) fs.rm(specPath, { force: true }).catch((error) => {
269
+ console.error(`[Treeport terminal host] Failed to remove launch spec ${specPath}:`, error instanceof Error ? error.message : String(error));
270
+ });
271
+ });
272
+ }
273
+ get sessionCount() {
274
+ return this.sessions.size;
275
+ }
276
+ size(terminalId) {
277
+ const session = this.sessions.get(terminalId);
278
+ return session ? {
279
+ cols: session.terminal.cols,
280
+ rows: session.terminal.rows
281
+ } : null;
282
+ }
283
+ runtimeState(terminalId) {
284
+ const session = this.sessions.get(terminalId);
285
+ return session ? {
286
+ title: session.title,
287
+ status: session.status,
288
+ progress: session.progress,
289
+ bell: session.lastBellAt === null ? null : {
290
+ sequence: session.bellSequence,
291
+ at: session.lastBellAt
292
+ }
293
+ } : null;
294
+ }
295
+ async snapshot(terminalId) {
296
+ const session = this.sessions.get(terminalId);
297
+ if (!session) return null;
298
+ this.pauseBoundary(session);
299
+ try {
300
+ await this.drainParser(session);
301
+ if (this.sessions.get(terminalId) !== session) return null;
302
+ const links = [];
303
+ const terminalInternals = Object(session.terminal);
304
+ for (const [bufferName, buffer] of [["normal", session.terminal.buffer.normal], ["alternate", session.terminal.buffer.alternate]]) for (let lineIndex = 0; lineIndex < buffer.length; lineIndex += 1) {
305
+ const line = buffer.getLine(lineIndex);
306
+ if (!line) continue;
307
+ let activeLink = null;
308
+ for (let column = 0; column <= line.length; column += 1) {
309
+ const cell = column < line.length ? line.getCell(column) : void 0;
310
+ const linkId = (cell ? Object(cell) : void 0)?.extended?.urlId;
311
+ if (activeLink !== null && linkId === activeLink.id) continue;
312
+ if (activeLink) {
313
+ const data = terminalInternals._core._oscLinkService.getLinkData(activeLink.id);
314
+ if (data?.uri && data.uri.length <= 4096 && links.length < 1e4) links.push({
315
+ buffer: bufferName,
316
+ uri: data.uri,
317
+ line: lineIndex,
318
+ startColumn: activeLink.startColumn,
319
+ endColumn: column
320
+ });
321
+ }
322
+ activeLink = linkId ? {
323
+ id: linkId,
324
+ startColumn: column
325
+ } : null;
326
+ }
327
+ }
328
+ return {
329
+ data: session.serializer.serialize({ scrollback: HOST_SCROLLBACK_LINES }),
330
+ links,
331
+ fence: session.outputSequence,
332
+ cols: session.terminal.cols,
333
+ rows: session.terminal.rows
334
+ };
335
+ } finally {
336
+ this.releaseBoundary(session);
337
+ }
338
+ }
339
+ subscribeOutput(terminalId, listener) {
340
+ const session = this.sessions.get(terminalId);
341
+ if (!session) throw new Error("Terminal is unavailable");
342
+ session.outputListeners.add(listener);
343
+ return () => session.outputListeners.delete(listener);
344
+ }
345
+ subscribeRuntime(terminalId, listener) {
346
+ const session = this.sessions.get(terminalId);
347
+ if (!session) return () => void 0;
348
+ session.runtimeListeners.add(listener);
349
+ return () => session.runtimeListeners.delete(listener);
350
+ }
351
+ async write(terminalId, data, authority) {
352
+ const session = this.sessions.get(terminalId);
353
+ if (!session || session.status !== "running" || session.queryAuthorityAttachmentId !== authority.attachmentId || session.queryAuthorityGeneration !== authority.generation || session.queryTransitionId !== null) return;
354
+ session.pty.write(data);
355
+ }
356
+ async prepareQueryAuthority(terminalId) {
357
+ const session = this.sessions.get(terminalId);
358
+ if (!session || session.status !== "running") throw new Error("Terminal is unavailable");
359
+ if (session.queryTransitionId !== null) throw new Error("A terminal query authority change is already pending");
360
+ this.pauseBoundary(session);
361
+ await this.drainParser(session);
362
+ if (this.sessions.get(terminalId) !== session) throw new Error("Terminal is unavailable");
363
+ session.queryAuthorityAttachmentId = null;
364
+ session.queryAuthorityGeneration = null;
365
+ session.terminal.options.disableStdin = false;
366
+ session.queryTransitionId = crypto.randomUUID();
367
+ return {
368
+ transitionId: session.queryTransitionId,
369
+ fence: session.outputSequence
370
+ };
371
+ }
372
+ async activateQueryAuthority(terminalId, transitionId, attachmentId, generation) {
373
+ const session = this.sessions.get(terminalId);
374
+ if (!session || session.status !== "running" || session.queryTransitionId !== transitionId) throw new Error("Terminal query authority transition is unavailable");
375
+ session.terminal.options.disableStdin = true;
376
+ session.queryAuthorityAttachmentId = attachmentId;
377
+ session.queryAuthorityGeneration = generation;
378
+ session.queryTransitionId = null;
379
+ this.releaseBoundary(session);
380
+ }
381
+ async useHostQueryAuthority(terminalId) {
382
+ const session = this.sessions.get(terminalId);
383
+ if (!session || session.status !== "running") return;
384
+ if (session.queryTransitionId === null) {
385
+ this.pauseBoundary(session);
386
+ await this.drainParser(session);
387
+ if (this.sessions.get(terminalId) !== session) return;
388
+ }
389
+ session.terminal.options.disableStdin = false;
390
+ session.queryAuthorityAttachmentId = null;
391
+ session.queryAuthorityGeneration = null;
392
+ if (session.queryTransitionId !== null) session.queryTransitionId = null;
393
+ this.releaseBoundary(session);
394
+ }
395
+ async restoreHostQueryAuthority() {
396
+ await Promise.all([...this.sessions.keys()].map((terminalId) => this.useHostQueryAuthority(terminalId)));
397
+ }
398
+ async resize(terminalId, cols, rows) {
399
+ const session = this.sessions.get(terminalId);
400
+ if (!session || session.status !== "running") return;
401
+ this.pauseBoundary(session);
402
+ try {
403
+ await this.drainParser(session);
404
+ if (this.sessions.get(terminalId) !== session) return;
405
+ session.pty.resize(cols, rows);
406
+ session.terminal.resize(cols, rows);
407
+ } finally {
408
+ this.releaseBoundary(session);
409
+ }
410
+ }
411
+ async listTerminals(worktreeId) {
412
+ return [...this.sessions.values()].filter((session) => session.worktreeId === worktreeId).map(({ pty: _pty, terminal: _terminal, serializer: _serializer, dataDisposable: _data, exitDisposable: _exit, outputSequence: _outputSequence, parserQueue: _parserQueue, parserQueuedBytes: _parserQueuedBytes, parserWriting: _parserWriting, parserPaused: _parserPaused, parserWaiters: _parserWaiters, boundaryPauseCount: _boundaryPauseCount, queryAuthorityAttachmentId: _queryAuthorityAttachmentId, queryAuthorityGeneration: _queryAuthorityGeneration, queryTransitionId: _queryTransitionId, outputListeners: _outputs, runtimeListeners: _runtime, cwd: _cwd, specPath: _specPath, title: _title, commandLine: _commandLine, progress: _progress, progressLease: _progressLease, bellSequence: _bellSequence, lastBellAt: _lastBellAt, ...terminal }) => ({ ...terminal }));
413
+ }
414
+ async terminalState(terminalId) {
415
+ const session = this.sessions.get(terminalId);
416
+ return session ? {
417
+ status: session.status,
418
+ exitCode: session.exitCode
419
+ } : {
420
+ status: "missing",
421
+ exitCode: null
422
+ };
423
+ }
424
+ async terminalSize(terminalId) {
425
+ const session = this.sessions.get(terminalId);
426
+ return session ? {
427
+ cols: session.terminal.cols,
428
+ rows: session.terminal.rows
429
+ } : null;
430
+ }
431
+ async captureTerminal(terminalId, lines) {
432
+ const session = this.sessions.get(terminalId);
433
+ if (!session) return null;
434
+ await new Promise((resolve) => session.terminal.write("", resolve));
435
+ const buffer = session.terminal.buffer.active;
436
+ const content = [];
437
+ for (let index = 0; index < buffer.length; index += 1) content.push(buffer.getLine(index)?.translateToString(true) ?? "");
438
+ while (content.length && !content.at(-1)?.trim()) content.pop();
439
+ return content.slice(-lines).join("\n");
440
+ }
441
+ async renameTerminal(terminalId, name, updatedAt) {
442
+ const session = this.sessions.get(terminalId);
443
+ if (session) {
444
+ session.name = name;
445
+ session.updatedAt = updatedAt;
446
+ }
447
+ }
448
+ async listProcesses(worktreeId) {
449
+ return [...this.sessions.values()].filter((session) => session.worktreeId === worktreeId && session.status === "running").map((session) => ({
450
+ pid: session.pty.pid,
451
+ terminalId: session.id
452
+ }));
453
+ }
454
+ async terminalTitleState(terminalId) {
455
+ const session = this.sessions.get(terminalId);
456
+ if (!session) return null;
457
+ return {
458
+ terminalTitle: session.title,
459
+ currentCommand: session.pty.process || null,
460
+ commandLine: session.commandLine
461
+ };
462
+ }
463
+ async signalTerminal(terminalId, signal) {
464
+ const session = this.sessions.get(terminalId);
465
+ if (session?.status === "running") session.pty.kill(signal);
466
+ }
467
+ async killTerminal(terminalId) {
468
+ const session = this.sessions.get(terminalId);
469
+ if (session) await this.destroy(session);
470
+ }
471
+ async killWorktree(worktreeId) {
472
+ const sessions = [...this.sessions.values()].filter((session) => session.worktreeId === worktreeId);
473
+ const terminalIds = sessions.map((session) => session.id);
474
+ const started = sessions.map((session) => this.destroy(session));
475
+ const alreadyPending = [...this.pendingCleanups].filter((cleanup) => cleanup.worktreeId === worktreeId).map((cleanup) => cleanup.promise);
476
+ await Promise.all([...started, ...alreadyPending]);
477
+ return terminalIds;
478
+ }
479
+ async shutdown() {
480
+ const started = [...this.sessions.values()].map((session) => this.destroy(session));
481
+ await Promise.all([...started, ...[...this.pendingCleanups].map((cleanup) => cleanup.promise)]);
482
+ }
483
+ parseNext(session) {
484
+ if (session.parserWriting || this.sessions.get(session.id) !== session) return;
485
+ const next = session.parserQueue.shift();
486
+ if (!next) {
487
+ for (const resolve of session.parserWaiters) resolve();
488
+ session.parserWaiters.clear();
489
+ return;
490
+ }
491
+ session.parserWriting = true;
492
+ session.terminal.write(next.data, () => {
493
+ if (this.sessions.get(session.id) !== session) return;
494
+ session.parserWriting = false;
495
+ session.parserQueuedBytes = Math.max(0, session.parserQueuedBytes - next.bytes);
496
+ for (const listener of [...session.outputListeners]) listener(next.data, next.sequence);
497
+ if (session.parserPaused && session.parserQueuedBytes <= HOST_PARSER_LOW_WATERMARK) {
498
+ session.parserPaused = false;
499
+ if (session.boundaryPauseCount === 0) session.pty.resume();
500
+ }
501
+ this.parseNext(session);
502
+ });
503
+ }
504
+ drainParser(session) {
505
+ if (!session.parserWriting && session.parserQueue.length === 0) return Promise.resolve();
506
+ return new Promise((resolve) => session.parserWaiters.add(resolve));
507
+ }
508
+ pauseBoundary(session) {
509
+ session.boundaryPauseCount += 1;
510
+ if (session.boundaryPauseCount === 1) session.pty.pause();
511
+ }
512
+ releaseBoundary(session) {
513
+ session.boundaryPauseCount = Math.max(0, session.boundaryPauseCount - 1);
514
+ if (this.sessions.get(session.id) === session && session.status === "running" && session.boundaryPauseCount === 0 && !session.parserPaused) session.pty.resume();
515
+ }
516
+ destroy(session) {
517
+ if (this.sessions.get(session.id) !== session) return Promise.resolve();
518
+ this.sessions.delete(session.id);
519
+ session.dataDisposable?.dispose();
520
+ session.exitDisposable?.dispose();
521
+ if (session.progressLease) {
522
+ clearTimeout(session.progressLease);
523
+ session.progressLease = null;
524
+ }
525
+ session.outputListeners.clear();
526
+ session.runtimeListeners.clear();
527
+ for (const resolve of session.parserWaiters) resolve();
528
+ session.parserWaiters.clear();
529
+ session.parserQueue = [];
530
+ session.serializer.dispose();
531
+ session.terminal.dispose();
532
+ const physicalCleanup = Promise.all([session.specPath ? fs.rm(session.specPath, { force: true }) : Promise.resolve(), this.terminateProcessTree(session.pty)]).then(() => void 0);
533
+ const ownedCleanup = {
534
+ worktreeId: session.worktreeId,
535
+ promise: physicalCleanup
536
+ };
537
+ const cleanup = physicalCleanup.finally(() => this.pendingCleanups.delete(ownedCleanup));
538
+ ownedCleanup.promise = cleanup;
539
+ this.pendingCleanups.add(ownedCleanup);
540
+ cleanup.catch(() => void 0);
541
+ return cleanup;
542
+ }
543
+ };
544
+ //#endregion
545
+ //#region src/server/terminal-host-server.ts
546
+ const TERMINAL_HOST_MAX_QUEUED_BYTES = 4 * 1024 * 1024;
547
+ function tokensMatch(actual, expected) {
548
+ const actualBuffer = Buffer.from(actual);
549
+ const expectedBuffer = Buffer.from(expected);
550
+ return actualBuffer.byteLength === expectedBuffer.byteLength && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
551
+ }
552
+ async function startTerminalHostServer(options) {
553
+ await options.sessions.initialize();
554
+ const record = {
555
+ protocolVersion: 3,
556
+ hostId: options.hostId,
557
+ hostKey: options.hostKey,
558
+ pid: options.pid ?? process.pid,
559
+ socketPath: options.socketPath,
560
+ startedAt: options.startedAt ?? (/* @__PURE__ */ new Date()).toISOString()
561
+ };
562
+ const connections = /* @__PURE__ */ new Set();
563
+ let closing = false;
564
+ let shuttingDown = false;
565
+ const send = (connection, frame) => {
566
+ if (connection.socket.destroyed) return false;
567
+ const encoded = encodeTerminalHostFrame(frame);
568
+ if (connection.writeBlocked) {
569
+ if (connection.queuedBytes + encoded.byteLength > TERMINAL_HOST_MAX_QUEUED_BYTES) {
570
+ connection.socket.destroy();
571
+ return false;
572
+ }
573
+ const queuedFrame = {
574
+ encoded,
575
+ next: null
576
+ };
577
+ if (connection.queuedFrameTail) connection.queuedFrameTail.next = queuedFrame;
578
+ else connection.queuedFrameHead = queuedFrame;
579
+ connection.queuedFrameTail = queuedFrame;
580
+ connection.queuedBytes += encoded.byteLength;
581
+ return true;
582
+ }
583
+ try {
584
+ connection.writeBlocked = !connection.socket.write(encoded);
585
+ return true;
586
+ } catch {
587
+ connection.socket.destroy();
588
+ return false;
589
+ }
590
+ };
591
+ const flush = (connection) => {
592
+ if (connection.socket.destroyed) return;
593
+ connection.writeBlocked = false;
594
+ while (connection.queuedFrameHead) {
595
+ const queuedFrame = connection.queuedFrameHead;
596
+ connection.queuedFrameHead = queuedFrame.next;
597
+ if (!connection.queuedFrameHead) connection.queuedFrameTail = null;
598
+ connection.queuedBytes -= queuedFrame.encoded.byteLength;
599
+ try {
600
+ if (!connection.socket.write(queuedFrame.encoded)) {
601
+ connection.writeBlocked = true;
602
+ return;
603
+ }
604
+ } catch {
605
+ connection.socket.destroy();
606
+ return;
607
+ }
608
+ }
609
+ };
610
+ const respond = (connection, id, result) => send(connection, {
611
+ protocolVersion: 3,
612
+ type: "response",
613
+ id,
614
+ result,
615
+ error: null
616
+ });
617
+ const fail = (connection, id, code, message, details = {}) => send(connection, {
618
+ protocolVersion: 3,
619
+ type: "response",
620
+ id,
621
+ result: null,
622
+ error: {
623
+ code,
624
+ message,
625
+ ...details
626
+ }
627
+ });
628
+ const handleRequest = async (connection, frame) => {
629
+ if (frame.protocolVersion !== 3) {
630
+ fail(connection, frame.id, "INCOMPATIBLE_PROTOCOL", `Terminal host protocol 3 is not compatible with daemon protocol ${frame.protocolVersion}`, {
631
+ hostProtocolVersion: 3,
632
+ liveSessionCount: options.sessions.sessionCount
633
+ });
634
+ return;
635
+ }
636
+ if (!connection.authenticated) {
637
+ if (frame.method !== "handshake") {
638
+ fail(connection, frame.id, "AUTH_REQUIRED", "Handshake is required");
639
+ connection.socket.destroy();
640
+ return;
641
+ }
642
+ const input = terminalHostInputSchemas.handshake.parse(frame.input);
643
+ if (!tokensMatch(input.token ?? "", options.token)) {
644
+ fail(connection, frame.id, "AUTH_FAILED", "Authentication failed");
645
+ connection.socket.destroy();
646
+ return;
647
+ }
648
+ if (input.hostKey !== options.hostKey) {
649
+ fail(connection, frame.id, "HOST_MISMATCH", "Terminal host key differs");
650
+ connection.socket.destroy();
651
+ return;
652
+ }
653
+ if (input.protocolVersion !== 3) {
654
+ fail(connection, frame.id, "INCOMPATIBLE_PROTOCOL", `Terminal host protocol 3 is not compatible with daemon protocol ${input.protocolVersion}`, {
655
+ hostProtocolVersion: 3,
656
+ liveSessionCount: options.sessions.sessionCount
657
+ });
658
+ return;
659
+ }
660
+ connection.authenticated = true;
661
+ respond(connection, frame.id, {
662
+ ...record,
663
+ liveSessionCount: options.sessions.sessionCount,
664
+ traceContext: true
665
+ });
666
+ return;
667
+ }
668
+ if (shuttingDown) {
669
+ fail(connection, frame.id, "HOST_SHUTTING_DOWN", "The terminal host is shutting down");
670
+ return;
671
+ }
672
+ switch (frame.method) {
673
+ case "handshake":
674
+ fail(connection, frame.id, "ALREADY_AUTHENTICATED", "Handshake is complete");
675
+ return;
676
+ case "create":
677
+ await options.sessions.createTerminal(terminalHostInputSchemas.create.parse(frame.input));
678
+ respond(connection, frame.id, null);
679
+ return;
680
+ case "inventory": {
681
+ const input = terminalHostInputSchemas.inventory.parse(frame.input);
682
+ respond(connection, frame.id, await options.sessions.listTerminals(input.worktreeId));
683
+ return;
684
+ }
685
+ case "state": {
686
+ const input = terminalHostInputSchemas.state.parse(frame.input);
687
+ respond(connection, frame.id, await options.sessions.terminalState(input.terminalId));
688
+ return;
689
+ }
690
+ case "attach": {
691
+ const input = terminalHostInputSchemas.attach.parse(frame.input);
692
+ connection.outputUnsubscribes.get(input.terminalId)?.();
693
+ const unsubscribe = options.sessions.subscribeOutput(input.terminalId, (output, sequence) => {
694
+ send(connection, {
695
+ protocolVersion: 3,
696
+ type: "event",
697
+ event: "output",
698
+ data: {
699
+ terminalId: input.terminalId,
700
+ output,
701
+ sequence
702
+ }
703
+ });
704
+ });
705
+ connection.outputUnsubscribes.set(input.terminalId, unsubscribe);
706
+ const snapshot = await options.sessions.snapshot(input.terminalId);
707
+ if (snapshot === null) {
708
+ unsubscribe();
709
+ connection.outputUnsubscribes.delete(input.terminalId);
710
+ }
711
+ respond(connection, frame.id, snapshot);
712
+ return;
713
+ }
714
+ case "unsubscribeOutput": {
715
+ const input = terminalHostInputSchemas.unsubscribeOutput.parse(frame.input);
716
+ connection.outputUnsubscribes.get(input.terminalId)?.();
717
+ connection.outputUnsubscribes.delete(input.terminalId);
718
+ respond(connection, frame.id, null);
719
+ return;
720
+ }
721
+ case "subscribeRuntime": {
722
+ const input = terminalHostInputSchemas.subscribeRuntime.parse(frame.input);
723
+ connection.runtimeUnsubscribes.get(input.terminalId)?.();
724
+ connection.runtimeUnsubscribes.set(input.terminalId, options.sessions.subscribeRuntime(input.terminalId, (value) => {
725
+ send(connection, {
726
+ protocolVersion: 3,
727
+ type: "event",
728
+ event: "runtime",
729
+ data: {
730
+ terminalId: input.terminalId,
731
+ value
732
+ }
733
+ });
734
+ }));
735
+ respond(connection, frame.id, null);
736
+ return;
737
+ }
738
+ case "unsubscribeRuntime": {
739
+ const input = terminalHostInputSchemas.unsubscribeRuntime.parse(frame.input);
740
+ connection.runtimeUnsubscribes.get(input.terminalId)?.();
741
+ connection.runtimeUnsubscribes.delete(input.terminalId);
742
+ respond(connection, frame.id, null);
743
+ return;
744
+ }
745
+ case "runtimeState": {
746
+ const input = terminalHostInputSchemas.runtimeState.parse(frame.input);
747
+ respond(connection, frame.id, options.sessions.runtimeState(input.terminalId));
748
+ return;
749
+ }
750
+ case "write": {
751
+ const input = terminalHostInputSchemas.write.parse(frame.input);
752
+ options.sessions.write(input.terminalId, input.encoding === "base64" ? Buffer.from(input.data, "base64") : input.data, input.authority);
753
+ respond(connection, frame.id, null);
754
+ return;
755
+ }
756
+ case "prepareQueryAuthority": {
757
+ const input = terminalHostInputSchemas.prepareQueryAuthority.parse(frame.input);
758
+ respond(connection, frame.id, await options.sessions.prepareQueryAuthority(input.terminalId));
759
+ return;
760
+ }
761
+ case "activateQueryAuthority": {
762
+ const input = terminalHostInputSchemas.activateQueryAuthority.parse(frame.input);
763
+ await options.sessions.activateQueryAuthority(input.terminalId, input.transitionId, input.attachmentId, input.generation);
764
+ respond(connection, frame.id, null);
765
+ return;
766
+ }
767
+ case "hostQueryAuthority": {
768
+ const input = terminalHostInputSchemas.hostQueryAuthority.parse(frame.input);
769
+ await options.sessions.useHostQueryAuthority(input.terminalId);
770
+ respond(connection, frame.id, null);
771
+ return;
772
+ }
773
+ case "resize": {
774
+ const input = terminalHostInputSchemas.resize.parse(frame.input);
775
+ await options.sessions.resize(input.terminalId, input.cols, input.rows);
776
+ respond(connection, frame.id, null);
777
+ return;
778
+ }
779
+ case "capture": {
780
+ const input = terminalHostInputSchemas.capture.parse(frame.input);
781
+ respond(connection, frame.id, await options.sessions.captureTerminal(input.terminalId, input.lines));
782
+ return;
783
+ }
784
+ case "rename": {
785
+ const input = terminalHostInputSchemas.rename.parse(frame.input);
786
+ await options.sessions.renameTerminal(input.terminalId, input.name, input.updatedAt);
787
+ respond(connection, frame.id, null);
788
+ return;
789
+ }
790
+ case "processes": {
791
+ const input = terminalHostInputSchemas.processes.parse(frame.input);
792
+ respond(connection, frame.id, await options.sessions.listProcesses(input.worktreeId));
793
+ return;
794
+ }
795
+ case "titleState": {
796
+ const input = terminalHostInputSchemas.titleState.parse(frame.input);
797
+ respond(connection, frame.id, await options.sessions.terminalTitleState(input.terminalId));
798
+ return;
799
+ }
800
+ case "signal": {
801
+ const input = terminalHostInputSchemas.signal.parse(frame.input);
802
+ await options.sessions.signalTerminal(input.terminalId, input.signal);
803
+ respond(connection, frame.id, null);
804
+ return;
805
+ }
806
+ case "kill": {
807
+ const input = terminalHostInputSchemas.kill.parse(frame.input);
808
+ await options.sessions.killTerminal(input.terminalId);
809
+ respond(connection, frame.id, null);
810
+ return;
811
+ }
812
+ case "killWorktree": {
813
+ const input = terminalHostInputSchemas.killWorktree.parse(frame.input);
814
+ respond(connection, frame.id, await options.sessions.killWorktree(input.worktreeId));
815
+ return;
816
+ }
817
+ case "shutdown":
818
+ terminalHostInputSchemas.shutdown.parse(frame.input);
819
+ if (options.sessions.sessionCount > 0) {
820
+ fail(connection, frame.id, "HOST_NOT_EMPTY", "The terminal host still owns live or exited sessions", { liveSessionCount: options.sessions.sessionCount });
821
+ return;
822
+ }
823
+ shuttingDown = true;
824
+ await options.sessions.shutdown().catch((error) => {
825
+ shuttingDown = false;
826
+ throw error;
827
+ });
828
+ respond(connection, frame.id, null);
829
+ setImmediate(() => {
830
+ close().then(() => options.onShutdown?.()).catch((error) => {
831
+ console.error("[Treeport terminal host] Requested shutdown failed:", error instanceof Error ? error.message : String(error));
832
+ });
833
+ });
834
+ }
835
+ };
836
+ const server = net.createServer((socket) => {
837
+ socket.setNoDelay(true);
838
+ const connection = {
839
+ socket,
840
+ authenticated: false,
841
+ decoder: new TerminalHostFrameDecoder(),
842
+ outputUnsubscribes: /* @__PURE__ */ new Map(),
843
+ runtimeUnsubscribes: /* @__PURE__ */ new Map(),
844
+ requestTail: Promise.resolve(),
845
+ writeBlocked: false,
846
+ queuedFrameHead: null,
847
+ queuedFrameTail: null,
848
+ queuedBytes: 0
849
+ };
850
+ connections.add(connection);
851
+ const release = () => {
852
+ connections.delete(connection);
853
+ connection.queuedFrameHead = null;
854
+ connection.queuedFrameTail = null;
855
+ connection.queuedBytes = 0;
856
+ for (const unsubscribe of connection.outputUnsubscribes.values()) unsubscribe();
857
+ for (const unsubscribe of connection.runtimeUnsubscribes.values()) unsubscribe();
858
+ connection.outputUnsubscribes.clear();
859
+ connection.runtimeUnsubscribes.clear();
860
+ if (connection.authenticated) options.sessions.restoreHostQueryAuthority().catch((error) => {
861
+ console.error("[Treeport terminal host] Failed to restore query authority after a client disconnected:", error instanceof Error ? error.message : String(error));
862
+ });
863
+ };
864
+ socket.once("close", release);
865
+ socket.on("error", () => void 0);
866
+ socket.on("drain", () => flush(connection));
867
+ socket.on("data", (chunk) => {
868
+ let frames;
869
+ try {
870
+ frames = connection.decoder.push(chunk);
871
+ } catch {
872
+ socket.destroy();
873
+ return;
874
+ }
875
+ for (const frame of frames) {
876
+ if (frame.type !== "request") {
877
+ socket.destroy();
878
+ return;
879
+ }
880
+ const admittedAt = Date.now();
881
+ const run = () => {
882
+ const evaluate = () => handleRequest(connection, frame);
883
+ return (frame.trace && options.trace ? options.trace(frame.method === "create" ? "treeport.terminal_host.pty.create" : frame.method === "attach" ? "treeport.terminal_host.attach" : frame.method === "kill" ? "treeport.terminal_host.pty.remove" : "treeport.terminal_host.request", frame.trace, {
884
+ "treeport.terminal_host.method": frame.method,
885
+ "treeport.terminal_host.queue_wait_ms": Date.now() - admittedAt
886
+ }, evaluate) : evaluate()).catch((error) => {
887
+ fail(connection, frame.id, "REQUEST_FAILED", error instanceof Error ? error.message : String(error));
888
+ });
889
+ };
890
+ if (connection.authenticated && frame.method === "kill") connection.requestTail.then(run);
891
+ else connection.requestTail = connection.requestTail.then(run);
892
+ }
893
+ });
894
+ });
895
+ await new Promise((resolve, reject) => {
896
+ server.once("error", reject);
897
+ server.listen(options.socketPath, () => {
898
+ server.off("error", reject);
899
+ resolve();
900
+ });
901
+ });
902
+ await fs.chmod(options.socketPath, 384);
903
+ const temporaryRecordPath = `${options.recordPath}.${process.pid}.tmp`;
904
+ await fs.writeFile(temporaryRecordPath, `${JSON.stringify(record)}\n`, { mode: 384 });
905
+ await fs.rename(temporaryRecordPath, options.recordPath);
906
+ async function close() {
907
+ if (closing) return;
908
+ closing = true;
909
+ for (const connection of connections) connection.socket.destroy();
910
+ await new Promise((resolve) => server.close(() => resolve()));
911
+ if (await fs.readFile(options.recordPath, "utf8").then((value) => {
912
+ const parsed = terminalHostRecordSchema.safeParse(JSON.parse(value));
913
+ return parsed.success && parsed.data.hostId === options.hostId;
914
+ }).catch(() => false)) await fs.rm(options.recordPath, { force: true });
915
+ await fs.rm(options.socketPath, { force: true });
916
+ }
917
+ return {
918
+ record,
919
+ close
920
+ };
921
+ }
922
+ //#endregion
923
+ //#region src/server/terminal-host-entry.ts
924
+ function requiredEnvironment(name) {
925
+ const value = process.env[name]?.trim();
926
+ if (!value) throw new Error(`${name} is required`);
927
+ return value;
928
+ }
929
+ async function main() {
930
+ const sessions = new TerminalHostSessionManager(requiredEnvironment("TREEPORT_TERMINAL_HOST_RUNTIME_DIR"), requiredEnvironment("TREEPORT_TERMINAL_HOST_LAUNCHER"));
931
+ const traceRuntime = makeHostTraceRuntime(process.env.TREEPORT_APP_VERSION ?? "unknown");
932
+ const trace = traceRuntime ? (name, parent, attributes, evaluate) => traceRuntime.run(name, parent, attributes, evaluate) : void 0;
933
+ const options = {
934
+ hostId: requiredEnvironment("TREEPORT_TERMINAL_HOST_ID"),
935
+ hostKey: requiredEnvironment("TREEPORT_TERMINAL_HOST_KEY"),
936
+ token: requiredEnvironment("TREEPORT_TERMINAL_HOST_TOKEN"),
937
+ socketPath: requiredEnvironment("TREEPORT_TERMINAL_HOST_SOCKET"),
938
+ recordPath: requiredEnvironment("TREEPORT_TERMINAL_HOST_RECORD"),
939
+ sessions,
940
+ onShutdown: async () => {
941
+ await sessions.shutdown();
942
+ await traceRuntime?.dispose();
943
+ process.exit(0);
944
+ }
945
+ };
946
+ if (trace) options.trace = trace;
947
+ const host = await startTerminalHostServer(options);
948
+ let stopping = false;
949
+ const stop = () => {
950
+ if (stopping) return;
951
+ stopping = true;
952
+ host.close().then(() => sessions.shutdown()).then(() => traceRuntime?.dispose()).then(() => process.exit(0), (error) => {
953
+ console.error("[Treeport terminal host] Shutdown failed:", error instanceof Error ? error.message : String(error));
954
+ process.exit(1);
955
+ });
956
+ };
957
+ process.once("SIGINT", stop);
958
+ process.once("SIGTERM", stop);
959
+ }
960
+ await main();
961
+ //#endregion
962
+ export {};