@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +93 -0
- package/README.zh.md +93 -0
- package/lib/client.js +677 -0
- package/lib/index.js +844 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1131 -0
- package/lib/typert.remote-client.d.ts +49 -0
- package/lib/typert.remote-client.js +529 -0
- package/lib/types/client/close-requests.d.ts +31 -0
- package/lib/types/client/close-requests.js +75 -0
- package/lib/types/client/index.d.ts +88 -0
- package/lib/types/client/index.js +157 -0
- package/lib/types/client/model.d.ts +119 -0
- package/lib/types/client/model.js +323 -0
- package/lib/types/client/shell-preference.d.ts +11 -0
- package/lib/types/client/shell-preference.js +26 -0
- package/lib/types/index.d.ts +135 -0
- package/lib/types/index.js +371 -0
- package/lib/types/shells.d.ts +21 -0
- package/lib/types/shells.js +54 -0
- package/lib/types/stream.d.ts +29 -0
- package/lib/types/stream.js +78 -0
- package/lib/types/terminal.d.ts +61 -0
- package/lib/types/terminal.js +168 -0
- package/lib/types/types.d.ts +69 -0
- package/lib/types/types.js +2 -0
- package/package.json +93 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
+
import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
|
|
5
|
+
import { Deque } from "@deepseek-ai/dsh-deque";
|
|
6
|
+
//#region lib/types/shells.js
|
|
7
|
+
/** Shell selection and executable verification use the target execution provider. */
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the configured shell or the execution environment's default shell.
|
|
10
|
+
* @param subprocess - target execution provider.
|
|
11
|
+
* @param configured - optional profile overriding the environment's default shell.
|
|
12
|
+
* @param signal - resolution cancellation.
|
|
13
|
+
* @returns one verified shell; a declared default that cannot resolve rejects.
|
|
14
|
+
*/
|
|
15
|
+
async function resolveShell(subprocess, configured, signal) {
|
|
16
|
+
let shell = configured;
|
|
17
|
+
if (shell === void 0) {
|
|
18
|
+
const environment = await subprocess.terminalEnvironment(signal);
|
|
19
|
+
shell = profile(environment.defaultShell ?? (environment.platform === "windows" ? "cmd.exe" : "/bin/sh"));
|
|
20
|
+
}
|
|
21
|
+
const path = await subprocess.resolveExecutable(shell.path, void 0, signal);
|
|
22
|
+
return {
|
|
23
|
+
...shell,
|
|
24
|
+
path
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function profile(path) {
|
|
28
|
+
const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
|
|
29
|
+
const kind = name.toLowerCase().replace(/\.exe$/u, "");
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
name,
|
|
33
|
+
args: kind === "cmd" ? [] : kind === "pwsh" || kind === "powershell" ? ["-NoLogo"] : ["-i"]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* List verified candidates after the configured or environment-default shell.
|
|
38
|
+
* @param subprocess - target execution provider.
|
|
39
|
+
* @param configured - optional default profile.
|
|
40
|
+
* @param candidates - executable names or paths permitted for shell selection.
|
|
41
|
+
* @param signal - discovery cancellation.
|
|
42
|
+
* @returns unique installed shells, with the default first; transport failures reject.
|
|
43
|
+
*/
|
|
44
|
+
async function discoverShells(subprocess, configured, candidates, signal) {
|
|
45
|
+
const preferred = await resolveShell(subprocess, configured, signal);
|
|
46
|
+
const found = await Promise.all(candidates.map(async (candidate) => {
|
|
47
|
+
try {
|
|
48
|
+
return await resolveShell(subprocess, profile(candidate), signal);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof SubprocessExecutableNotFoundError) return void 0;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
const shells = /* @__PURE__ */ new Map();
|
|
55
|
+
for (const shell of [preferred, ...found]) {
|
|
56
|
+
if (shell === void 0) continue;
|
|
57
|
+
const key = shell.path.includes("\\") ? shell.path.toLowerCase() : shell.path;
|
|
58
|
+
if (!shells.has(key)) shells.set(key, shell);
|
|
59
|
+
}
|
|
60
|
+
return [...shells.values()];
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region lib/types/stream.js
|
|
64
|
+
/** A bounded output queue for one Remote stream generation. */
|
|
65
|
+
/** Slow followers fail explicitly; a later attachment recovers from the screen. */
|
|
66
|
+
var TerminalFollower = class {
|
|
67
|
+
maxBytes;
|
|
68
|
+
queue = new Deque();
|
|
69
|
+
bytes = 0;
|
|
70
|
+
wake;
|
|
71
|
+
closed = false;
|
|
72
|
+
finished = false;
|
|
73
|
+
failure;
|
|
74
|
+
/** @param maxBytes - maximum queued UTF-8 bytes for this follower. */
|
|
75
|
+
constructor(maxBytes) {
|
|
76
|
+
this.maxBytes = maxBytes;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Queue a frame or fail this follower when its byte limit is exceeded.
|
|
80
|
+
* @param frame - next ordered frame.
|
|
81
|
+
*/
|
|
82
|
+
push(frame) {
|
|
83
|
+
if (this.closed || this.finished) return;
|
|
84
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8");
|
|
85
|
+
if (this.bytes + bytes > this.maxBytes) {
|
|
86
|
+
this.failure = /* @__PURE__ */ new Error("Terminal output consumer exceeded its buffer; reconnect to recover the current screen");
|
|
87
|
+
this.close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.queue.pushBack({
|
|
91
|
+
frame,
|
|
92
|
+
bytes
|
|
93
|
+
});
|
|
94
|
+
this.bytes += bytes;
|
|
95
|
+
this.wake?.();
|
|
96
|
+
}
|
|
97
|
+
/** Finish after delivering every queued frame, including the final exit state. */
|
|
98
|
+
finish() {
|
|
99
|
+
this.finished = true;
|
|
100
|
+
this.wake?.();
|
|
101
|
+
}
|
|
102
|
+
/** Stop this follower without stopping its terminal. */
|
|
103
|
+
close() {
|
|
104
|
+
this.closed = true;
|
|
105
|
+
this.queue.clear();
|
|
106
|
+
this.bytes = 0;
|
|
107
|
+
this.wake?.();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Drain until detached or failed.
|
|
111
|
+
* @param signal - Remote generation cancellation.
|
|
112
|
+
* @returns ordered terminal frames.
|
|
113
|
+
*/
|
|
114
|
+
async *read(signal) {
|
|
115
|
+
const abort = () => {
|
|
116
|
+
this.close();
|
|
117
|
+
};
|
|
118
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
119
|
+
if (signal.aborted) abort();
|
|
120
|
+
try {
|
|
121
|
+
while (!this.closed) {
|
|
122
|
+
const next = this.queue.popFront();
|
|
123
|
+
if (next !== void 0) {
|
|
124
|
+
this.bytes -= next.bytes;
|
|
125
|
+
yield next.frame;
|
|
126
|
+
} else {
|
|
127
|
+
if (this.finished) break;
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
this.wake = resolve;
|
|
130
|
+
});
|
|
131
|
+
this.wake = void 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (this.failure !== void 0) throw this.failure;
|
|
135
|
+
} finally {
|
|
136
|
+
signal.removeEventListener("abort", abort);
|
|
137
|
+
this.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region lib/types/terminal.js
|
|
143
|
+
/** One PTY, a bounded terminal emulator and its detachable browser followers. */
|
|
144
|
+
const { Terminal, SerializeAddon } = loadXterm();
|
|
145
|
+
function loadXterm() {
|
|
146
|
+
const require = createRequire(import.meta.url);
|
|
147
|
+
const { Terminal } = require("@xterm/headless");
|
|
148
|
+
const { SerializeAddon } = require("@xterm/addon-serialize");
|
|
149
|
+
return {
|
|
150
|
+
Terminal,
|
|
151
|
+
SerializeAddon
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Process lifetime is independent of follower and component lifetimes. */
|
|
155
|
+
var BrowserTerminal = class {
|
|
156
|
+
handle;
|
|
157
|
+
info;
|
|
158
|
+
maxBufferedBytes;
|
|
159
|
+
screen;
|
|
160
|
+
serializer;
|
|
161
|
+
followers = /* @__PURE__ */ new Set();
|
|
162
|
+
sequence = 0;
|
|
163
|
+
operations = Promise.resolve();
|
|
164
|
+
drained;
|
|
165
|
+
closing;
|
|
166
|
+
controller;
|
|
167
|
+
/**
|
|
168
|
+
* @param handle - allocated terminal process range.
|
|
169
|
+
* @param info - initial metadata.
|
|
170
|
+
* @param scrollback - maximum retained scrollback rows.
|
|
171
|
+
* @param maxBufferedBytes - per-follower queue cap.
|
|
172
|
+
*/
|
|
173
|
+
constructor(handle, info, scrollback, maxBufferedBytes) {
|
|
174
|
+
this.handle = handle;
|
|
175
|
+
this.info = info;
|
|
176
|
+
this.maxBufferedBytes = maxBufferedBytes;
|
|
177
|
+
this.screen = new Terminal({
|
|
178
|
+
cols: info.cols,
|
|
179
|
+
rows: info.rows,
|
|
180
|
+
scrollback,
|
|
181
|
+
allowProposedApi: true
|
|
182
|
+
});
|
|
183
|
+
this.serializer = new SerializeAddon();
|
|
184
|
+
this.screen.loadAddon(this.serializer);
|
|
185
|
+
this.drained = this.consume();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Attach with exclusive input control; an older attachment becomes read-only.
|
|
189
|
+
* @param id - browser attachment identity.
|
|
190
|
+
* @param signal - attachment cancellation; never terminates the process.
|
|
191
|
+
* @returns a consistent screen followed by ordered output and state changes.
|
|
192
|
+
*/
|
|
193
|
+
async *follow(id, signal) {
|
|
194
|
+
signal.throwIfAborted();
|
|
195
|
+
const follower = new TerminalFollower(this.maxBufferedBytes);
|
|
196
|
+
const baseline = await this.enqueue(() => {
|
|
197
|
+
signal.throwIfAborted();
|
|
198
|
+
this.controller = {
|
|
199
|
+
id,
|
|
200
|
+
follower
|
|
201
|
+
};
|
|
202
|
+
this.info = {
|
|
203
|
+
...this.info,
|
|
204
|
+
controllerId: id
|
|
205
|
+
};
|
|
206
|
+
this.broadcast({
|
|
207
|
+
type: "state",
|
|
208
|
+
info: this.info
|
|
209
|
+
});
|
|
210
|
+
const snapshot = {
|
|
211
|
+
type: "snapshot",
|
|
212
|
+
sequence: this.sequence,
|
|
213
|
+
screen: this.serializer.serialize(),
|
|
214
|
+
info: this.info
|
|
215
|
+
};
|
|
216
|
+
this.followers.add(follower);
|
|
217
|
+
return snapshot;
|
|
218
|
+
});
|
|
219
|
+
try {
|
|
220
|
+
yield baseline;
|
|
221
|
+
yield* follower.read(signal);
|
|
222
|
+
} finally {
|
|
223
|
+
this.followers.delete(follower);
|
|
224
|
+
follower.close();
|
|
225
|
+
if (this.controller?.follower === follower) {
|
|
226
|
+
this.controller = void 0;
|
|
227
|
+
const { controllerId: _controllerId, ...info } = this.info;
|
|
228
|
+
this.info = info;
|
|
229
|
+
this.broadcast({
|
|
230
|
+
type: "state",
|
|
231
|
+
info
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Send raw terminal input without command interpretation.
|
|
238
|
+
* @param id - current writable attachment.
|
|
239
|
+
* @param data - UTF-8 input, including shell completion/control keys.
|
|
240
|
+
* @returns when the provider accepts the input.
|
|
241
|
+
*/
|
|
242
|
+
write(id, data) {
|
|
243
|
+
return this.enqueue(async () => {
|
|
244
|
+
this.requireController(id);
|
|
245
|
+
await this.handle.write(data);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Resize the PTY and recovery screen in the same operation order as output.
|
|
250
|
+
* @param id - current writable attachment.
|
|
251
|
+
* @param cols - validated column count.
|
|
252
|
+
* @param rows - validated row count.
|
|
253
|
+
* @returns when the provider and emulator use the new dimensions.
|
|
254
|
+
*/
|
|
255
|
+
resize(id, cols, rows) {
|
|
256
|
+
return this.enqueue(async () => {
|
|
257
|
+
this.requireController(id);
|
|
258
|
+
await this.handle.resize(cols, rows);
|
|
259
|
+
this.screen.resize(cols, rows);
|
|
260
|
+
this.info = {
|
|
261
|
+
...this.info,
|
|
262
|
+
cols,
|
|
263
|
+
rows
|
|
264
|
+
};
|
|
265
|
+
this.broadcast({
|
|
266
|
+
type: "state",
|
|
267
|
+
info: this.info
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Publish a display name to every attached view.
|
|
273
|
+
* @param title - validated user title.
|
|
274
|
+
*/
|
|
275
|
+
rename(title) {
|
|
276
|
+
this.info = {
|
|
277
|
+
...this.info,
|
|
278
|
+
title
|
|
279
|
+
};
|
|
280
|
+
this.broadcast({
|
|
281
|
+
type: "state",
|
|
282
|
+
info: this.info
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Terminate the complete provider-owned process range before releasing its screen.
|
|
287
|
+
* @returns after process cleanup and final output drainage; failures remain retryable.
|
|
288
|
+
*/
|
|
289
|
+
close() {
|
|
290
|
+
if (this.closing !== void 0) return this.closing;
|
|
291
|
+
this.closing = (async () => {
|
|
292
|
+
await this.handle.terminate();
|
|
293
|
+
await this.drained;
|
|
294
|
+
for (const follower of this.followers) follower.finish();
|
|
295
|
+
this.followers.clear();
|
|
296
|
+
this.screen.dispose();
|
|
297
|
+
})().catch((error) => {
|
|
298
|
+
this.closing = void 0;
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
return this.closing;
|
|
302
|
+
}
|
|
303
|
+
requireController(id) {
|
|
304
|
+
if (this.closing !== void 0 || this.info.state !== "running") throw new RemoteError("terminal/control-unavailable", "Terminal is not running", { reason: "not-running" });
|
|
305
|
+
if (this.controller?.id !== id) throw new RemoteError("terminal/control-unavailable", "Terminal input is controlled by another attachment", { reason: "read-only" });
|
|
306
|
+
}
|
|
307
|
+
broadcast(frame) {
|
|
308
|
+
for (const follower of this.followers) follower.push(frame);
|
|
309
|
+
}
|
|
310
|
+
enqueue(operation) {
|
|
311
|
+
const pending = this.operations.then(operation);
|
|
312
|
+
this.operations = pending.catch(() => {});
|
|
313
|
+
return pending;
|
|
314
|
+
}
|
|
315
|
+
async consume() {
|
|
316
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
317
|
+
const outcome = this.handle.done.then((value) => ({ value }), (error) => ({ error }));
|
|
318
|
+
try {
|
|
319
|
+
for await (const chunk of this.handle.output) {
|
|
320
|
+
const data = decoder.decode(chunk, { stream: true });
|
|
321
|
+
await this.output(data);
|
|
322
|
+
}
|
|
323
|
+
await this.output(decoder.decode());
|
|
324
|
+
const result = await outcome;
|
|
325
|
+
if ("error" in result) throw result.error;
|
|
326
|
+
this.info = {
|
|
327
|
+
...this.info,
|
|
328
|
+
state: "exited",
|
|
329
|
+
exitCode: result.value.exitCode
|
|
330
|
+
};
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.info = {
|
|
333
|
+
...this.info,
|
|
334
|
+
state: "failed",
|
|
335
|
+
error: error instanceof Error ? error.message : String(error)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
this.broadcast({
|
|
339
|
+
type: "state",
|
|
340
|
+
info: this.info
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async output(data) {
|
|
344
|
+
if (data.length === 0) return;
|
|
345
|
+
await this.enqueue(async () => {
|
|
346
|
+
await new Promise((resolve) => {
|
|
347
|
+
this.screen.write(data, resolve);
|
|
348
|
+
});
|
|
349
|
+
this.broadcast({
|
|
350
|
+
type: "output",
|
|
351
|
+
sequence: ++this.sequence,
|
|
352
|
+
data
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region lib/types/index.js
|
|
359
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
360
|
+
var useValue = arguments.length > 2;
|
|
361
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
362
|
+
return useValue ? value : void 0;
|
|
363
|
+
};
|
|
364
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
365
|
+
function accept(f) {
|
|
366
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
367
|
+
return f;
|
|
368
|
+
}
|
|
369
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
370
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
371
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
372
|
+
var _, done = false;
|
|
373
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
374
|
+
var context = {};
|
|
375
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
376
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
377
|
+
context.addInitializer = function(f) {
|
|
378
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
379
|
+
extraInitializers.push(accept(f || null));
|
|
380
|
+
};
|
|
381
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
382
|
+
get: descriptor.get,
|
|
383
|
+
set: descriptor.set
|
|
384
|
+
} : descriptor[key], context);
|
|
385
|
+
if (kind === "accessor") {
|
|
386
|
+
if (result === void 0) continue;
|
|
387
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
388
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
389
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
390
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
391
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
392
|
+
else descriptor[key] = _;
|
|
393
|
+
}
|
|
394
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
395
|
+
done = true;
|
|
396
|
+
};
|
|
397
|
+
/** Typed Remote control of transient Session-owned terminal processes. */
|
|
398
|
+
let TerminalController = (() => {
|
|
399
|
+
let _classSuper = TypertRemoteService;
|
|
400
|
+
let _instanceExtraInitializers = [];
|
|
401
|
+
let _environment_decorators;
|
|
402
|
+
let _shells_decorators;
|
|
403
|
+
let _list_decorators;
|
|
404
|
+
let _create_decorators;
|
|
405
|
+
let _follow_decorators;
|
|
406
|
+
let _write_decorators;
|
|
407
|
+
let _resize_decorators;
|
|
408
|
+
let _rename_decorators;
|
|
409
|
+
let _close_decorators;
|
|
410
|
+
return class TerminalController extends _classSuper {
|
|
411
|
+
static {
|
|
412
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
413
|
+
_environment_decorators = [Remote];
|
|
414
|
+
_shells_decorators = [Remote];
|
|
415
|
+
_list_decorators = [Remote];
|
|
416
|
+
_create_decorators = [Remote];
|
|
417
|
+
_follow_decorators = [Remote({ mode: "stream" })];
|
|
418
|
+
_write_decorators = [Remote];
|
|
419
|
+
_resize_decorators = [Remote];
|
|
420
|
+
_rename_decorators = [Remote];
|
|
421
|
+
_close_decorators = [Remote];
|
|
422
|
+
__esDecorate(this, null, _environment_decorators, {
|
|
423
|
+
kind: "method",
|
|
424
|
+
name: "environment",
|
|
425
|
+
static: false,
|
|
426
|
+
private: false,
|
|
427
|
+
access: {
|
|
428
|
+
has: (obj) => "environment" in obj,
|
|
429
|
+
get: (obj) => obj.environment
|
|
430
|
+
},
|
|
431
|
+
metadata: _metadata
|
|
432
|
+
}, null, _instanceExtraInitializers);
|
|
433
|
+
__esDecorate(this, null, _shells_decorators, {
|
|
434
|
+
kind: "method",
|
|
435
|
+
name: "shells",
|
|
436
|
+
static: false,
|
|
437
|
+
private: false,
|
|
438
|
+
access: {
|
|
439
|
+
has: (obj) => "shells" in obj,
|
|
440
|
+
get: (obj) => obj.shells
|
|
441
|
+
},
|
|
442
|
+
metadata: _metadata
|
|
443
|
+
}, null, _instanceExtraInitializers);
|
|
444
|
+
__esDecorate(this, null, _list_decorators, {
|
|
445
|
+
kind: "method",
|
|
446
|
+
name: "list",
|
|
447
|
+
static: false,
|
|
448
|
+
private: false,
|
|
449
|
+
access: {
|
|
450
|
+
has: (obj) => "list" in obj,
|
|
451
|
+
get: (obj) => obj.list
|
|
452
|
+
},
|
|
453
|
+
metadata: _metadata
|
|
454
|
+
}, null, _instanceExtraInitializers);
|
|
455
|
+
__esDecorate(this, null, _create_decorators, {
|
|
456
|
+
kind: "method",
|
|
457
|
+
name: "create",
|
|
458
|
+
static: false,
|
|
459
|
+
private: false,
|
|
460
|
+
access: {
|
|
461
|
+
has: (obj) => "create" in obj,
|
|
462
|
+
get: (obj) => obj.create
|
|
463
|
+
},
|
|
464
|
+
metadata: _metadata
|
|
465
|
+
}, null, _instanceExtraInitializers);
|
|
466
|
+
__esDecorate(this, null, _follow_decorators, {
|
|
467
|
+
kind: "method",
|
|
468
|
+
name: "follow",
|
|
469
|
+
static: false,
|
|
470
|
+
private: false,
|
|
471
|
+
access: {
|
|
472
|
+
has: (obj) => "follow" in obj,
|
|
473
|
+
get: (obj) => obj.follow
|
|
474
|
+
},
|
|
475
|
+
metadata: _metadata
|
|
476
|
+
}, null, _instanceExtraInitializers);
|
|
477
|
+
__esDecorate(this, null, _write_decorators, {
|
|
478
|
+
kind: "method",
|
|
479
|
+
name: "write",
|
|
480
|
+
static: false,
|
|
481
|
+
private: false,
|
|
482
|
+
access: {
|
|
483
|
+
has: (obj) => "write" in obj,
|
|
484
|
+
get: (obj) => obj.write
|
|
485
|
+
},
|
|
486
|
+
metadata: _metadata
|
|
487
|
+
}, null, _instanceExtraInitializers);
|
|
488
|
+
__esDecorate(this, null, _resize_decorators, {
|
|
489
|
+
kind: "method",
|
|
490
|
+
name: "resize",
|
|
491
|
+
static: false,
|
|
492
|
+
private: false,
|
|
493
|
+
access: {
|
|
494
|
+
has: (obj) => "resize" in obj,
|
|
495
|
+
get: (obj) => obj.resize
|
|
496
|
+
},
|
|
497
|
+
metadata: _metadata
|
|
498
|
+
}, null, _instanceExtraInitializers);
|
|
499
|
+
__esDecorate(this, null, _rename_decorators, {
|
|
500
|
+
kind: "method",
|
|
501
|
+
name: "rename",
|
|
502
|
+
static: false,
|
|
503
|
+
private: false,
|
|
504
|
+
access: {
|
|
505
|
+
has: (obj) => "rename" in obj,
|
|
506
|
+
get: (obj) => obj.rename
|
|
507
|
+
},
|
|
508
|
+
metadata: _metadata
|
|
509
|
+
}, null, _instanceExtraInitializers);
|
|
510
|
+
__esDecorate(this, null, _close_decorators, {
|
|
511
|
+
kind: "method",
|
|
512
|
+
name: "close",
|
|
513
|
+
static: false,
|
|
514
|
+
private: false,
|
|
515
|
+
access: {
|
|
516
|
+
has: (obj) => "close" in obj,
|
|
517
|
+
get: (obj) => obj.close
|
|
518
|
+
},
|
|
519
|
+
metadata: _metadata
|
|
520
|
+
}, null, _instanceExtraInitializers);
|
|
521
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
522
|
+
enumerable: true,
|
|
523
|
+
configurable: true,
|
|
524
|
+
writable: true,
|
|
525
|
+
value: _metadata
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
529
|
+
static inject = [
|
|
530
|
+
"subprocess",
|
|
531
|
+
"sandboxPolicy",
|
|
532
|
+
"sessionProjections",
|
|
533
|
+
"typert"
|
|
534
|
+
];
|
|
535
|
+
static Config = z.object({
|
|
536
|
+
shell: z.union([z.object({
|
|
537
|
+
path: z.string().required(),
|
|
538
|
+
name: z.string().required(),
|
|
539
|
+
args: z.array(z.string()).default([])
|
|
540
|
+
}), z.const(void 0)]),
|
|
541
|
+
shellCandidates: z.array(z.string().min(1)).default([
|
|
542
|
+
"zsh",
|
|
543
|
+
"bash",
|
|
544
|
+
"fish",
|
|
545
|
+
"pwsh",
|
|
546
|
+
"powershell",
|
|
547
|
+
"cmd"
|
|
548
|
+
]),
|
|
549
|
+
maxTerminals: z.number().step(1).min(1).default(8),
|
|
550
|
+
maxCols: z.number().step(1).min(2).default(500),
|
|
551
|
+
maxRows: z.number().step(1).min(1).default(200),
|
|
552
|
+
scrollback: z.number().step(1).min(0).default(1e3),
|
|
553
|
+
maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
|
|
554
|
+
maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
|
|
555
|
+
disposeGraceMs: z.number().step(1).min(1).default(1e3)
|
|
556
|
+
});
|
|
557
|
+
owners = /* @__PURE__ */ new Map();
|
|
558
|
+
lifetime = new AbortController();
|
|
559
|
+
/**
|
|
560
|
+
* @param ctx - Host context carrying typed Remote and execution providers.
|
|
561
|
+
* @param config - validated terminal limits and optional shell profile.
|
|
562
|
+
*/
|
|
563
|
+
constructor(ctx, config) {
|
|
564
|
+
super(ctx, "terminalController", { namespace: "terminal" });
|
|
565
|
+
this.config = config;
|
|
566
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
567
|
+
if (eventName !== "session/event") return;
|
|
568
|
+
const [session, event] = args;
|
|
569
|
+
if (event.type !== "sandbox/mode") return;
|
|
570
|
+
const owner = this.owners.get(session.id);
|
|
571
|
+
if (owner === void 0 || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return;
|
|
572
|
+
const current = ctx.sessionProjections.stateOf(session, "sandboxMode") ?? ctx.sandboxPolicy.defaultMode;
|
|
573
|
+
if (event.data.mode !== current) throw new Error("Close browser terminals before changing the Session sandbox mode");
|
|
574
|
+
}, { global: true });
|
|
575
|
+
ctx.effect(() => async () => {
|
|
576
|
+
this.lifetime.abort(/* @__PURE__ */ new Error("Terminal controller disposed"));
|
|
577
|
+
const errors = (await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
578
|
+
if (errors.length > 0) throw new AggregateError(errors, "Browser terminal cleanup failed");
|
|
579
|
+
}, "terminal-controller.processes");
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Read the Session working directory and terminal limits without resolving a shell.
|
|
583
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
584
|
+
* @param signal - request cancellation.
|
|
585
|
+
* @returns the Session workspace directory and terminal limits.
|
|
586
|
+
*/
|
|
587
|
+
environment(agent, signal) {
|
|
588
|
+
signal.throwIfAborted();
|
|
589
|
+
const { sandboxPolicy } = this.execution(agent);
|
|
590
|
+
return {
|
|
591
|
+
cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
|
|
592
|
+
maxInputBytes: this.config.maxInputBytes,
|
|
593
|
+
maxCols: this.config.maxCols,
|
|
594
|
+
maxRows: this.config.maxRows,
|
|
595
|
+
scrollback: this.config.scrollback
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Discover installed shells in the Session's execution environment.
|
|
600
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
601
|
+
* @param signal - request cancellation.
|
|
602
|
+
* @returns verified profiles, with the configured or system default first.
|
|
603
|
+
*/
|
|
604
|
+
shells(agent, signal) {
|
|
605
|
+
signal.throwIfAborted();
|
|
606
|
+
return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* List retained terminals without resolving or activating an Agent.
|
|
610
|
+
* @param sessionId - displayed Session identity, including offline history.
|
|
611
|
+
* @returns terminals retained for this Host lifetime.
|
|
612
|
+
*/
|
|
613
|
+
list(sessionId) {
|
|
614
|
+
const owner = this.owners.get(sessionId);
|
|
615
|
+
if (owner === void 0) return [];
|
|
616
|
+
return [...owner.terminals.values(), ...owner.allocations.values()].map((terminal) => terminal.info);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Allocate an interactive shell once for a caller-generated identity.
|
|
620
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
621
|
+
* @param request - initial dimensions and idempotency identity.
|
|
622
|
+
* @param signal - allocation cancellation; committed terminals survive disconnection.
|
|
623
|
+
* @returns the existing or newly committed terminal.
|
|
624
|
+
*/
|
|
625
|
+
async create(agent, request, signal) {
|
|
626
|
+
this.lifetime.signal.throwIfAborted();
|
|
627
|
+
if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error("Invalid terminal identity");
|
|
628
|
+
this.dimensions(request.cols, request.rows);
|
|
629
|
+
const owner = this.owner(agent);
|
|
630
|
+
owner.lifetime.signal.throwIfAborted();
|
|
631
|
+
this.requireOpen(owner, request.id);
|
|
632
|
+
const existing = owner.terminals.get(request.id);
|
|
633
|
+
if (existing !== void 0) return existing.info;
|
|
634
|
+
const pending = owner.pending.get(request.id);
|
|
635
|
+
if (pending !== void 0) {
|
|
636
|
+
const terminal = await pending;
|
|
637
|
+
this.requireOpen(owner, request.id);
|
|
638
|
+
return terminal.info;
|
|
639
|
+
}
|
|
640
|
+
if (owner.allocations.has(request.id)) throw new Error("Close the failed terminal allocation before creating it again");
|
|
641
|
+
if (new Set([
|
|
642
|
+
...owner.terminals.keys(),
|
|
643
|
+
...owner.pending.keys(),
|
|
644
|
+
...owner.allocations.keys()
|
|
645
|
+
]).size >= this.config.maxTerminals) throw new RemoteError("terminal/limit-reached", "Session terminal limit reached", { limit: this.config.maxTerminals });
|
|
646
|
+
const allocation = this.spawn(agent, owner, request, AbortSignal.any([
|
|
647
|
+
signal,
|
|
648
|
+
this.lifetime.signal,
|
|
649
|
+
owner.lifetime.signal
|
|
650
|
+
]));
|
|
651
|
+
owner.pending.set(request.id, allocation);
|
|
652
|
+
try {
|
|
653
|
+
const terminal = await allocation;
|
|
654
|
+
owner.terminals.set(request.id, terminal);
|
|
655
|
+
owner.allocations.delete(request.id);
|
|
656
|
+
this.requireOpen(owner, request.id);
|
|
657
|
+
return terminal.info;
|
|
658
|
+
} finally {
|
|
659
|
+
owner.pending.delete(request.id);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Attach to a terminal without binding its process lifetime to the transport.
|
|
664
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
665
|
+
* @param id - terminal identity.
|
|
666
|
+
* @param attachmentId - new exclusive input attachment.
|
|
667
|
+
* @param signal - physical stream cancellation.
|
|
668
|
+
* @returns screen recovery followed by output and metadata changes.
|
|
669
|
+
*/
|
|
670
|
+
follow(agent, id, attachmentId, signal) {
|
|
671
|
+
if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error("Invalid terminal attachment identity");
|
|
672
|
+
return this.terminal(agent, id).follow(attachmentId, signal);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Deliver raw input, including Tab completion and control characters.
|
|
676
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
677
|
+
* @param id - terminal identity.
|
|
678
|
+
* @param attachmentId - current writable attachment.
|
|
679
|
+
* @param data - input bytes represented as UTF-8 text.
|
|
680
|
+
* @returns after provider input acceptance.
|
|
681
|
+
*/
|
|
682
|
+
async write(agent, id, attachmentId, data) {
|
|
683
|
+
if (Buffer.byteLength(data, "utf8") > this.config.maxInputBytes) throw new Error("Terminal input exceeds the configured limit");
|
|
684
|
+
await this.terminal(agent, id).write(attachmentId, data);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Update the dimensions of the PTY and recovery screen.
|
|
688
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
689
|
+
* @param id - terminal identity.
|
|
690
|
+
* @param attachmentId - current writable attachment.
|
|
691
|
+
* @param cols - column count.
|
|
692
|
+
* @param rows - row count.
|
|
693
|
+
* @returns after the resize completes.
|
|
694
|
+
*/
|
|
695
|
+
async resize(agent, id, attachmentId, cols, rows) {
|
|
696
|
+
this.dimensions(cols, rows);
|
|
697
|
+
await this.terminal(agent, id).resize(attachmentId, cols, rows);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Rename a terminal without changing its shell.
|
|
701
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
702
|
+
* @param id - terminal identity.
|
|
703
|
+
* @param title - nonempty display title, at most 120 characters.
|
|
704
|
+
*/
|
|
705
|
+
rename(agent, id, title) {
|
|
706
|
+
if (title.trim().length === 0 || title.length > 120) throw new Error("Terminal title must contain 1–120 characters");
|
|
707
|
+
this.terminal(agent, id).rename(title.trim());
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Close an identity to future creation and kill its process range; repeated closes succeed.
|
|
711
|
+
* @param agent - Session owner supplied by the Gateway.
|
|
712
|
+
* @param id - terminal identity.
|
|
713
|
+
* @returns after provider cleanup succeeds. A failure retains the terminal for retry.
|
|
714
|
+
*/
|
|
715
|
+
async close(agent, id) {
|
|
716
|
+
const owner = this.owner(agent);
|
|
717
|
+
owner.closedIds.add(id);
|
|
718
|
+
await owner.pending.get(id)?.catch(() => {});
|
|
719
|
+
const terminal = owner.terminals.get(id);
|
|
720
|
+
if (terminal !== void 0) {
|
|
721
|
+
await terminal.close();
|
|
722
|
+
owner.terminals.delete(id);
|
|
723
|
+
} else {
|
|
724
|
+
const allocation = owner.allocations.get(id);
|
|
725
|
+
if (allocation === void 0) return;
|
|
726
|
+
await allocation.handle.terminate();
|
|
727
|
+
owner.allocations.delete(id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
owner(agent) {
|
|
731
|
+
let owner = this.owners.get(agent.id);
|
|
732
|
+
if (owner === void 0) {
|
|
733
|
+
owner = {
|
|
734
|
+
terminals: /* @__PURE__ */ new Map(),
|
|
735
|
+
pending: /* @__PURE__ */ new Map(),
|
|
736
|
+
allocations: /* @__PURE__ */ new Map(),
|
|
737
|
+
closedIds: /* @__PURE__ */ new Set(),
|
|
738
|
+
lifetime: new AbortController()
|
|
739
|
+
};
|
|
740
|
+
this.owners.set(agent.id, owner);
|
|
741
|
+
const owned = owner;
|
|
742
|
+
agent.ctx.effect(() => async () => {
|
|
743
|
+
await this.disposeOwner(agent.id, owned);
|
|
744
|
+
}, "terminal-controller.owner");
|
|
745
|
+
}
|
|
746
|
+
return owner;
|
|
747
|
+
}
|
|
748
|
+
disposeOwner(id, owner) {
|
|
749
|
+
if (owner.cleanup !== void 0) return owner.cleanup;
|
|
750
|
+
owner.lifetime.abort(/* @__PURE__ */ new Error("Terminal Session owner disposed"));
|
|
751
|
+
owner.cleanup = (async () => {
|
|
752
|
+
await Promise.allSettled(owner.pending.values());
|
|
753
|
+
const errors = (await Promise.allSettled([...[...owner.terminals.values()].map((terminal) => terminal.close()), ...[...owner.allocations.values()].map((allocation) => allocation.handle.terminate())])).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
754
|
+
if (errors.length > 0) throw new AggregateError(errors, "Session terminal cleanup failed");
|
|
755
|
+
owner.terminals.clear();
|
|
756
|
+
owner.allocations.clear();
|
|
757
|
+
this.owners.delete(id);
|
|
758
|
+
})().catch((error) => {
|
|
759
|
+
delete owner.cleanup;
|
|
760
|
+
throw error;
|
|
761
|
+
});
|
|
762
|
+
return owner.cleanup;
|
|
763
|
+
}
|
|
764
|
+
terminal(agent, id) {
|
|
765
|
+
const terminal = this.owners.get(agent.id)?.terminals.get(id);
|
|
766
|
+
if (terminal === void 0) throw new Error("Terminal no longer exists in this Session");
|
|
767
|
+
return terminal;
|
|
768
|
+
}
|
|
769
|
+
requireOpen(owner, id) {
|
|
770
|
+
if (owner.closedIds.has(id)) throw new Error("Terminal was closed in this Session");
|
|
771
|
+
}
|
|
772
|
+
dimensions(cols, rows) {
|
|
773
|
+
if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error("Terminal dimensions exceed the configured limits");
|
|
774
|
+
}
|
|
775
|
+
execution(agent) {
|
|
776
|
+
const subprocess = agent.ctx.get("subprocess");
|
|
777
|
+
const sandboxPolicy = agent.ctx.get("sandboxPolicy");
|
|
778
|
+
if (subprocess === void 0 || sandboxPolicy === void 0) throw new Error("The Session execution environment requires subprocess and sandbox policy providers");
|
|
779
|
+
return {
|
|
780
|
+
subprocess,
|
|
781
|
+
sandboxPolicy
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
async spawn(agent, owner, request, signal) {
|
|
785
|
+
const environment = this.environment(agent, signal);
|
|
786
|
+
const { subprocess, sandboxPolicy } = this.execution(agent);
|
|
787
|
+
const shell = request.shellPath === void 0 ? await resolveShell(subprocess, this.config.shell, signal) : (await this.shells(agent, signal)).find((candidate) => candidate.path === request.shellPath);
|
|
788
|
+
if (shell === void 0) throw new Error("Selected shell is not available in this execution environment");
|
|
789
|
+
const policy = sandboxPolicy.resolve({ session: agent.session });
|
|
790
|
+
let argv = [shell.path, ...shell.args];
|
|
791
|
+
if (policy.mode !== "danger-full-access") {
|
|
792
|
+
const sandbox = agent.ctx.get("sandbox");
|
|
793
|
+
if (sandbox === void 0) throw new Error("The Session sandbox mode requires an execution sandbox provider");
|
|
794
|
+
argv = (await sandbox.confine(argv, {
|
|
795
|
+
...policy,
|
|
796
|
+
mode: policy.mode
|
|
797
|
+
}, signal)).argv;
|
|
798
|
+
}
|
|
799
|
+
const handle = await subprocess.spawnTerminal({
|
|
800
|
+
argv,
|
|
801
|
+
cwd: environment.cwd,
|
|
802
|
+
cols: request.cols,
|
|
803
|
+
rows: request.rows,
|
|
804
|
+
terminalType: "xterm-256color",
|
|
805
|
+
env: { DSH_SESSION_ID: agent.id },
|
|
806
|
+
graceMs: this.config.disposeGraceMs,
|
|
807
|
+
signal
|
|
808
|
+
});
|
|
809
|
+
const allocation = {
|
|
810
|
+
handle,
|
|
811
|
+
info: {
|
|
812
|
+
id: request.id,
|
|
813
|
+
shell,
|
|
814
|
+
title: shell.name,
|
|
815
|
+
cwd: environment.cwd,
|
|
816
|
+
cols: request.cols,
|
|
817
|
+
rows: request.rows,
|
|
818
|
+
state: "running",
|
|
819
|
+
exitCode: null
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
owner.allocations.set(request.id, allocation);
|
|
823
|
+
try {
|
|
824
|
+
signal.throwIfAborted();
|
|
825
|
+
return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
allocation.info = {
|
|
828
|
+
...allocation.info,
|
|
829
|
+
state: "failed",
|
|
830
|
+
error: error instanceof Error ? error.message : String(error)
|
|
831
|
+
};
|
|
832
|
+
try {
|
|
833
|
+
await handle.terminate();
|
|
834
|
+
owner.allocations.delete(request.id);
|
|
835
|
+
} catch (cleanupError) {
|
|
836
|
+
throw new AggregateError([error, cleanupError], "Terminal allocation cleanup failed");
|
|
837
|
+
}
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
})();
|
|
843
|
+
//#endregion
|
|
844
|
+
export { TerminalController, TerminalController as default };
|