@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.
- package/README.md +1 -1
- package/dist/dist-BsLn2Gbc.js +1630 -0
- package/dist/node/cli/index.js +95 -72
- package/dist/node/server/core/launcher.js +11 -49
- package/dist/node/server/index.js +10049 -8120
- package/dist/node/server/terminal-host-entry.js +962 -0
- package/dist/{shell-integration-Be_c91lw.js → shell-integration-CPmrVa3B.js} +46 -46
- package/dist/terminal-host-protocol-DZkQRAUF.js +378 -0
- package/dist/{update-qVp7yL5D.js → update-UYS2lMdD.js} +86 -56
- package/dist/web/assets/index-BWYDUD7N.css +2 -0
- package/dist/web/assets/index-C5cx0N4G.js +84 -0
- package/dist/web/index.html +2 -2
- package/drizzle/0012_terminal_host_cutover.sql +41 -0
- package/drizzle/0013_workspace_item_order.sql +13 -0
- package/drizzle/meta/0012_snapshot.json +919 -0
- package/drizzle/meta/0013_snapshot.json +987 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +18 -13
- package/skills/treeport/SKILL.md +4 -4
- package/dist/dist-Crk_Xr82.js +0 -735
- package/dist/web/assets/index-2LLiNn3-.js +0 -146
- package/dist/web/assets/index-DmDs47YU.css +0 -2
|
@@ -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 {};
|