@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1 → 0.1.7-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/README.i18n.yaml +2 -2
- package/README.md +14 -5
- package/README.zh.md +14 -5
- package/lib/client.js +249 -19
- package/lib/index.js +263 -61
- package/lib/typert.host.js +215 -100
- package/lib/typert.remote-client.d.ts +7 -4
- package/lib/typert.remote-client.js +156 -80
- package/lib/types/client/bindings.d.ts +31 -0
- package/lib/types/client/bindings.js +71 -0
- package/lib/types/client/index.d.ts +22 -4
- package/lib/types/client/index.js +83 -8
- package/lib/types/client/model.d.ts +3 -1
- package/lib/types/client/model.js +20 -7
- package/lib/types/client/retention.d.ts +35 -0
- package/lib/types/client/retention.js +77 -0
- package/lib/types/index.d.ts +17 -3
- package/lib/types/index.js +49 -43
- package/lib/types/retention.d.ts +53 -0
- package/lib/types/retention.js +153 -0
- package/lib/types/terminal.d.ts +23 -1
- package/lib/types/terminal.js +40 -9
- package/lib/types/types.d.ts +6 -0
- package/package.json +21 -19
package/lib/types/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import z from '@deepseek-ai/schemastery';
|
|
|
36
36
|
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
37
37
|
import { discoverShells, resolveShell } from "./shells.js";
|
|
38
38
|
import { BrowserTerminal } from "./terminal.js";
|
|
39
|
+
import { TerminalRetention } from "./retention.js";
|
|
39
40
|
/** Typed Remote control of transient Session-owned terminal processes. */
|
|
40
41
|
let TerminalController = (() => {
|
|
41
42
|
let _classSuper = TypertRemoteService;
|
|
@@ -44,6 +45,7 @@ let TerminalController = (() => {
|
|
|
44
45
|
let _shells_decorators;
|
|
45
46
|
let _list_decorators;
|
|
46
47
|
let _create_decorators;
|
|
48
|
+
let _retain_decorators;
|
|
47
49
|
let _follow_decorators;
|
|
48
50
|
let _write_decorators;
|
|
49
51
|
let _resize_decorators;
|
|
@@ -56,6 +58,7 @@ let TerminalController = (() => {
|
|
|
56
58
|
_shells_decorators = [Remote];
|
|
57
59
|
_list_decorators = [Remote];
|
|
58
60
|
_create_decorators = [Remote];
|
|
61
|
+
_retain_decorators = [Remote({ mode: 'stream' })];
|
|
59
62
|
_follow_decorators = [Remote({ mode: 'stream' })];
|
|
60
63
|
_write_decorators = [Remote];
|
|
61
64
|
_resize_decorators = [Remote];
|
|
@@ -65,6 +68,7 @@ let TerminalController = (() => {
|
|
|
65
68
|
__esDecorate(this, null, _shells_decorators, { kind: "method", name: "shells", static: false, private: false, access: { has: obj => "shells" in obj, get: obj => obj.shells }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
66
69
|
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
67
70
|
__esDecorate(this, null, _create_decorators, { kind: "method", name: "create", static: false, private: false, access: { has: obj => "create" in obj, get: obj => obj.create }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
71
|
+
__esDecorate(this, null, _retain_decorators, { kind: "method", name: "retain", static: false, private: false, access: { has: obj => "retain" in obj, get: obj => obj.retain }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
68
72
|
__esDecorate(this, null, _follow_decorators, { kind: "method", name: "follow", static: false, private: false, access: { has: obj => "follow" in obj, get: obj => obj.follow }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
69
73
|
__esDecorate(this, null, _write_decorators, { kind: "method", name: "write", static: false, private: false, access: { has: obj => "write" in obj, get: obj => obj.write }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
70
74
|
__esDecorate(this, null, _resize_decorators, { kind: "method", name: "resize", static: false, private: false, access: { has: obj => "resize" in obj, get: obj => obj.resize }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -73,7 +77,7 @@ let TerminalController = (() => {
|
|
|
73
77
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
74
78
|
}
|
|
75
79
|
config = __runInitializers(this, _instanceExtraInitializers);
|
|
76
|
-
static inject = ['subprocess', 'sandboxPolicy', '
|
|
80
|
+
static inject = ['subprocess', 'sandboxPolicy', 'typert'];
|
|
77
81
|
static Config = z.object({
|
|
78
82
|
shell: z.union([z.object({
|
|
79
83
|
path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
|
|
@@ -86,6 +90,9 @@ let TerminalController = (() => {
|
|
|
86
90
|
maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
|
|
87
91
|
maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
|
|
88
92
|
disposeGraceMs: z.number().step(1).min(1).default(1000),
|
|
93
|
+
unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000),
|
|
94
|
+
activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000),
|
|
95
|
+
cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000),
|
|
89
96
|
});
|
|
90
97
|
owners = new Map();
|
|
91
98
|
lifetime = new AbortController();
|
|
@@ -96,19 +103,6 @@ let TerminalController = (() => {
|
|
|
96
103
|
constructor(ctx, config) {
|
|
97
104
|
super(ctx, 'terminalController', { namespace: 'terminal' });
|
|
98
105
|
this.config = config;
|
|
99
|
-
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
|
100
|
-
if (eventName !== 'session/event')
|
|
101
|
-
return;
|
|
102
|
-
const [session, event] = args;
|
|
103
|
-
if (event.type !== 'sandbox/mode')
|
|
104
|
-
return;
|
|
105
|
-
const owner = this.owners.get(session.id);
|
|
106
|
-
if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0)
|
|
107
|
-
return;
|
|
108
|
-
const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode;
|
|
109
|
-
if (event.data.mode !== current)
|
|
110
|
-
throw new Error('Close browser terminals before changing the Session sandbox mode');
|
|
111
|
-
}, { global: true });
|
|
112
106
|
ctx.effect(() => async () => {
|
|
113
107
|
this.lifetime.abort(new Error('Terminal controller disposed'));
|
|
114
108
|
const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)));
|
|
@@ -126,7 +120,7 @@ let TerminalController = (() => {
|
|
|
126
120
|
environment(agent, signal) {
|
|
127
121
|
signal.throwIfAborted();
|
|
128
122
|
const { sandboxPolicy } = this.execution(agent);
|
|
129
|
-
return { cwd:
|
|
123
|
+
return { cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot,
|
|
130
124
|
maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
|
|
131
125
|
maxRows: this.config.maxRows, scrollback: this.config.scrollback };
|
|
132
126
|
}
|
|
@@ -152,7 +146,7 @@ let TerminalController = (() => {
|
|
|
152
146
|
return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info);
|
|
153
147
|
}
|
|
154
148
|
/**
|
|
155
|
-
* Allocate
|
|
149
|
+
* Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
|
|
156
150
|
* @param agent - Session owner supplied by the Gateway.
|
|
157
151
|
* @param request - initial dimensions and idempotency identity.
|
|
158
152
|
* @param signal - allocation cancellation; committed terminals survive disconnection.
|
|
@@ -175,8 +169,6 @@ let TerminalController = (() => {
|
|
|
175
169
|
this.requireOpen(owner, request.id);
|
|
176
170
|
return terminal.info;
|
|
177
171
|
}
|
|
178
|
-
if (owner.allocations.has(request.id))
|
|
179
|
-
throw new Error('Close the failed terminal allocation before creating it again');
|
|
180
172
|
if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals)
|
|
181
173
|
throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals });
|
|
182
174
|
const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]));
|
|
@@ -185,6 +177,9 @@ let TerminalController = (() => {
|
|
|
185
177
|
const terminal = await allocation;
|
|
186
178
|
owner.terminals.set(request.id, terminal);
|
|
187
179
|
owner.allocations.delete(request.id);
|
|
180
|
+
terminal.monitor(this.config, () => { owner.closedIds.add(request.id); }, () => {
|
|
181
|
+
owner.terminals.delete(request.id);
|
|
182
|
+
}, (error) => { this.ctx.logger.error('Browser terminal cleanup failed', error); });
|
|
188
183
|
this.requireOpen(owner, request.id);
|
|
189
184
|
return terminal.info;
|
|
190
185
|
}
|
|
@@ -192,6 +187,21 @@ let TerminalController = (() => {
|
|
|
192
187
|
owner.pending.delete(request.id);
|
|
193
188
|
}
|
|
194
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Retain an existing terminal for a window without activating its Agent or taking input control.
|
|
192
|
+
* @param sessionId - owning Session identity, including an inactive saved layout.
|
|
193
|
+
* @param id - retained Host terminal identity.
|
|
194
|
+
* @param signal - physical Remote stream cancellation.
|
|
195
|
+
* @returns a hold acknowledgement followed by an open lifetime stream.
|
|
196
|
+
*/
|
|
197
|
+
retain(sessionId, id, signal) {
|
|
198
|
+
const owner = this.owners.get(sessionId);
|
|
199
|
+
const terminal = owner?.terminals.get(id);
|
|
200
|
+
if (terminal === undefined || owner?.closedIds.has(id) === true || owner?.lifetime.signal.aborted === true) {
|
|
201
|
+
throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {});
|
|
202
|
+
}
|
|
203
|
+
return terminal.retain(signal);
|
|
204
|
+
}
|
|
195
205
|
/**
|
|
196
206
|
* Attach to a terminal without binding its process lifetime to the transport.
|
|
197
207
|
* @param agent - Session owner supplied by the Gateway.
|
|
@@ -262,7 +272,7 @@ let TerminalController = (() => {
|
|
|
262
272
|
const allocation = owner.allocations.get(id);
|
|
263
273
|
if (allocation === undefined)
|
|
264
274
|
return;
|
|
265
|
-
await allocation.
|
|
275
|
+
await allocation.cleanup.close();
|
|
266
276
|
owner.allocations.delete(id);
|
|
267
277
|
}
|
|
268
278
|
}
|
|
@@ -283,8 +293,8 @@ let TerminalController = (() => {
|
|
|
283
293
|
owner.cleanup = (async () => {
|
|
284
294
|
await Promise.allSettled(owner.pending.values());
|
|
285
295
|
const results = await Promise.allSettled([
|
|
286
|
-
...[...owner.terminals.values()].map(terminal => terminal.
|
|
287
|
-
...[...owner.allocations.values()].map(allocation => allocation.
|
|
296
|
+
...[...owner.terminals.values()].map(terminal => terminal.dispose()),
|
|
297
|
+
...[...owner.allocations.values()].map(allocation => allocation.cleanup.dispose()),
|
|
288
298
|
]);
|
|
289
299
|
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
290
300
|
if (errors.length > 0)
|
|
@@ -298,12 +308,13 @@ let TerminalController = (() => {
|
|
|
298
308
|
terminal(agent, id) {
|
|
299
309
|
const terminal = this.owners.get(agent.id)?.terminals.get(id);
|
|
300
310
|
if (terminal === undefined)
|
|
301
|
-
throw new
|
|
311
|
+
throw new RemoteError('terminal/unavailable', 'Terminal no longer exists in this Session', {});
|
|
312
|
+
this.requireOpen(this.owners.get(agent.id), id);
|
|
302
313
|
return terminal;
|
|
303
314
|
}
|
|
304
315
|
requireOpen(owner, id) {
|
|
305
316
|
if (owner.closedIds.has(id))
|
|
306
|
-
throw new
|
|
317
|
+
throw new RemoteError('terminal/unavailable', 'Terminal was closed in this Session', {});
|
|
307
318
|
}
|
|
308
319
|
dimensions(cols, rows) {
|
|
309
320
|
if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols
|
|
@@ -320,42 +331,37 @@ let TerminalController = (() => {
|
|
|
320
331
|
}
|
|
321
332
|
async spawn(agent, owner, request, signal) {
|
|
322
333
|
const environment = this.environment(agent, signal);
|
|
323
|
-
const { subprocess
|
|
334
|
+
const { subprocess } = this.execution(agent);
|
|
324
335
|
const shell = request.shellPath === undefined
|
|
325
336
|
? await resolveShell(subprocess, this.config.shell, signal)
|
|
326
337
|
: (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath);
|
|
327
338
|
if (shell === undefined)
|
|
328
339
|
throw new Error('Selected shell is not available in this execution environment');
|
|
329
|
-
const policy = sandboxPolicy.resolve({ session: agent.session });
|
|
330
|
-
let argv = [shell.path, ...shell.args];
|
|
331
|
-
if (policy.mode !== 'danger-full-access') {
|
|
332
|
-
const sandbox = agent.ctx.get('sandbox');
|
|
333
|
-
if (sandbox === undefined)
|
|
334
|
-
throw new Error('The Session sandbox mode requires an execution sandbox provider');
|
|
335
|
-
argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv;
|
|
336
|
-
}
|
|
337
340
|
const handle = await subprocess.spawnTerminal({
|
|
338
|
-
argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
|
|
341
|
+
argv: [shell.path, ...shell.args], cwd: environment.cwd, cols: request.cols, rows: request.rows,
|
|
339
342
|
terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
|
|
343
|
+
shellActivity: true,
|
|
340
344
|
graceMs: this.config.disposeGraceMs, signal,
|
|
341
345
|
});
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
id: request.id, shell, title: shell.name, cwd: environment.cwd,
|
|
346
|
-
cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
|
|
347
|
-
},
|
|
346
|
+
const info = {
|
|
347
|
+
id: request.id, shell, title: shell.name, cwd: environment.cwd,
|
|
348
|
+
cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
|
|
348
349
|
};
|
|
349
|
-
owner.allocations.set(request.id, allocation);
|
|
350
350
|
try {
|
|
351
351
|
signal.throwIfAborted();
|
|
352
|
-
return new BrowserTerminal(handle,
|
|
352
|
+
return new BrowserTerminal(handle, info, this.config.scrollback, this.config.maxBufferedBytes);
|
|
353
353
|
}
|
|
354
354
|
catch (error) {
|
|
355
|
-
|
|
356
|
-
|
|
355
|
+
const cleanup = new TerminalRetention(this.config, handle.inspectActivity.bind(handle), async () => {
|
|
356
|
+
owner.closedIds.add(request.id);
|
|
357
357
|
await handle.terminate();
|
|
358
358
|
owner.allocations.delete(request.id);
|
|
359
|
+
}, (cleanupError) => { this.ctx.logger.error('Browser terminal allocation cleanup failed', cleanupError); });
|
|
360
|
+
owner.allocations.set(request.id, {
|
|
361
|
+
info: { ...info, state: 'failed', error: error instanceof Error ? error.message : String(error) }, cleanup,
|
|
362
|
+
});
|
|
363
|
+
try {
|
|
364
|
+
await cleanup.close();
|
|
359
365
|
}
|
|
360
366
|
catch (cleanupError) {
|
|
361
367
|
throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SubprocessTerminalActivity } from '@deepseek-ai/dsh-subprocess';
|
|
2
|
+
import type { TerminalRetentionFrame } from './types.ts';
|
|
3
|
+
/** Validated Host timing policy for unattended terminal cleanup. */
|
|
4
|
+
export interface TerminalRetentionPolicy {
|
|
5
|
+
readonly unattendedTimeoutMs: number;
|
|
6
|
+
readonly activityPollIntervalMs: number;
|
|
7
|
+
readonly cleanupRetryMs: number;
|
|
8
|
+
}
|
|
9
|
+
/** Exactly one owner orders holds, observation, and retryable process cleanup. */
|
|
10
|
+
export declare class TerminalRetention {
|
|
11
|
+
private readonly policy;
|
|
12
|
+
private readonly inspect;
|
|
13
|
+
private readonly terminate;
|
|
14
|
+
private readonly failed;
|
|
15
|
+
private readonly lifetime;
|
|
16
|
+
private readonly holders;
|
|
17
|
+
private epoch;
|
|
18
|
+
private timer;
|
|
19
|
+
private observation;
|
|
20
|
+
private idle;
|
|
21
|
+
private closing;
|
|
22
|
+
private disposed;
|
|
23
|
+
private cleanup;
|
|
24
|
+
/**
|
|
25
|
+
* @param policy - deployment timing choices.
|
|
26
|
+
* @param inspect - fresh shell and owned-job observation.
|
|
27
|
+
* @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
|
|
28
|
+
* @param failed - diagnostic sink for failed automatic cleanup.
|
|
29
|
+
*/
|
|
30
|
+
constructor(policy: TerminalRetentionPolicy, inspect: () => Promise<SubprocessTerminalActivity>, terminate: () => Promise<void>, failed: (error: unknown) => void);
|
|
31
|
+
/**
|
|
32
|
+
* Hold one terminal for one physical Remote stream, independently of screen subscriptions.
|
|
33
|
+
* @param signal - transport generation lifetime.
|
|
34
|
+
* @returns acknowledgement followed by an open stream until cancellation or terminal closure.
|
|
35
|
+
*/
|
|
36
|
+
retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>;
|
|
37
|
+
/** Invalidate outstanding idle observations before accepting input. */
|
|
38
|
+
invalidate(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Start or join cleanup; failure keeps the identity closed and schedules one retry.
|
|
41
|
+
* @returns after owned process cleanup succeeds, or rejects with its failure.
|
|
42
|
+
*/
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Stop timers and streams and await both observation and final cleanup.
|
|
46
|
+
* @returns after process quiescence; cleanup failure is reported to the disposing owner.
|
|
47
|
+
*/
|
|
48
|
+
dispose(): Promise<void>;
|
|
49
|
+
private cancelTimer;
|
|
50
|
+
private schedule;
|
|
51
|
+
private observe;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=retention.d.ts.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Window holds and conservative idle reclamation for one terminal owner. */
|
|
2
|
+
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol';
|
|
3
|
+
/** Exactly one owner orders holds, observation, and retryable process cleanup. */
|
|
4
|
+
export class TerminalRetention {
|
|
5
|
+
policy;
|
|
6
|
+
inspect;
|
|
7
|
+
terminate;
|
|
8
|
+
failed;
|
|
9
|
+
lifetime = new AbortController();
|
|
10
|
+
holders = new Set();
|
|
11
|
+
epoch = 0;
|
|
12
|
+
timer;
|
|
13
|
+
observation;
|
|
14
|
+
idle;
|
|
15
|
+
closing = false;
|
|
16
|
+
disposed = false;
|
|
17
|
+
cleanup;
|
|
18
|
+
/**
|
|
19
|
+
* @param policy - deployment timing choices.
|
|
20
|
+
* @param inspect - fresh shell and owned-job observation.
|
|
21
|
+
* @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
|
|
22
|
+
* @param failed - diagnostic sink for failed automatic cleanup.
|
|
23
|
+
*/
|
|
24
|
+
constructor(policy, inspect, terminate, failed) {
|
|
25
|
+
this.policy = policy;
|
|
26
|
+
this.inspect = inspect;
|
|
27
|
+
this.terminate = terminate;
|
|
28
|
+
this.failed = failed;
|
|
29
|
+
this.schedule(0);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hold one terminal for one physical Remote stream, independently of screen subscriptions.
|
|
33
|
+
* @param signal - transport generation lifetime.
|
|
34
|
+
* @returns acknowledgement followed by an open stream until cancellation or terminal closure.
|
|
35
|
+
*/
|
|
36
|
+
async *retain(signal) {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
if (this.closing || this.disposed)
|
|
39
|
+
throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {});
|
|
40
|
+
const holder = {};
|
|
41
|
+
const ended = Promise.withResolvers();
|
|
42
|
+
const combined = AbortSignal.any([signal, this.lifetime.signal]);
|
|
43
|
+
const release = () => {
|
|
44
|
+
if (!this.holders.delete(holder))
|
|
45
|
+
return;
|
|
46
|
+
combined.removeEventListener('abort', release);
|
|
47
|
+
this.invalidate();
|
|
48
|
+
ended.resolve();
|
|
49
|
+
this.schedule(0);
|
|
50
|
+
};
|
|
51
|
+
this.holders.add(holder);
|
|
52
|
+
this.invalidate();
|
|
53
|
+
this.cancelTimer();
|
|
54
|
+
combined.addEventListener('abort', release, { once: true });
|
|
55
|
+
try {
|
|
56
|
+
yield { type: 'retained' };
|
|
57
|
+
await ended.promise;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
release();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Invalidate outstanding idle observations before accepting input. */
|
|
64
|
+
invalidate() { this.epoch++; this.idle = undefined; }
|
|
65
|
+
/**
|
|
66
|
+
* Start or join cleanup; failure keeps the identity closed and schedules one retry.
|
|
67
|
+
* @returns after owned process cleanup succeeds, or rejects with its failure.
|
|
68
|
+
*/
|
|
69
|
+
close() {
|
|
70
|
+
if (this.cleanup !== undefined)
|
|
71
|
+
return this.cleanup;
|
|
72
|
+
this.closing = true;
|
|
73
|
+
this.invalidate();
|
|
74
|
+
this.lifetime.abort(new Error('Terminal closed'));
|
|
75
|
+
this.cancelTimer();
|
|
76
|
+
this.cleanup = this.terminate().catch((error) => {
|
|
77
|
+
this.cleanup = undefined;
|
|
78
|
+
this.schedule(this.policy.cleanupRetryMs);
|
|
79
|
+
throw error;
|
|
80
|
+
});
|
|
81
|
+
return this.cleanup;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Stop timers and streams and await both observation and final cleanup.
|
|
85
|
+
* @returns after process quiescence; cleanup failure is reported to the disposing owner.
|
|
86
|
+
*/
|
|
87
|
+
async dispose() {
|
|
88
|
+
this.disposed = true;
|
|
89
|
+
this.cancelTimer();
|
|
90
|
+
const observation = this.observation;
|
|
91
|
+
try {
|
|
92
|
+
await this.close();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await observation;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
cancelTimer() { clearTimeout(this.timer); this.timer = undefined; }
|
|
99
|
+
schedule(delay) {
|
|
100
|
+
if (this.disposed || this.timer !== undefined)
|
|
101
|
+
return;
|
|
102
|
+
if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0))
|
|
103
|
+
return;
|
|
104
|
+
const due = performance.now() + delay;
|
|
105
|
+
this.timer = setTimeout(() => {
|
|
106
|
+
this.timer = undefined;
|
|
107
|
+
const remaining = due - performance.now();
|
|
108
|
+
if (remaining > 0) {
|
|
109
|
+
this.schedule(remaining);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.closing) {
|
|
113
|
+
void this.close().catch(this.failed);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.observe();
|
|
117
|
+
}, Math.min(delay, 2_147_483_647));
|
|
118
|
+
this.timer.unref();
|
|
119
|
+
}
|
|
120
|
+
observe() {
|
|
121
|
+
if (this.observation !== undefined)
|
|
122
|
+
return;
|
|
123
|
+
const epoch = this.epoch;
|
|
124
|
+
this.observation = (async () => {
|
|
125
|
+
let activity;
|
|
126
|
+
try {
|
|
127
|
+
activity = await this.inspect();
|
|
128
|
+
}
|
|
129
|
+
catch (_activityUnavailable) {
|
|
130
|
+
activity = { state: 'unknown', revision: 0 };
|
|
131
|
+
}
|
|
132
|
+
if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch)
|
|
133
|
+
return;
|
|
134
|
+
const now = performance.now();
|
|
135
|
+
if (activity.state !== 'idle') {
|
|
136
|
+
this.idle = undefined;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (this.idle?.revision !== activity.revision || now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2) {
|
|
140
|
+
this.idle = { since: now, observedAt: now, revision: activity.revision };
|
|
141
|
+
}
|
|
142
|
+
else
|
|
143
|
+
this.idle.observedAt = now;
|
|
144
|
+
if (now - this.idle.since >= this.policy.unattendedTimeoutMs)
|
|
145
|
+
await this.close();
|
|
146
|
+
})().catch(this.failed).finally(() => {
|
|
147
|
+
this.observation = undefined;
|
|
148
|
+
if (!this.closing)
|
|
149
|
+
this.schedule(this.policy.activityPollIntervalMs);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=retention.js.map
|
package/lib/types/terminal.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess';
|
|
2
|
-
import
|
|
2
|
+
import { type TerminalRetentionPolicy } from './retention.ts';
|
|
3
|
+
import type { TerminalAttachmentId, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts';
|
|
3
4
|
/** Process lifetime is independent of follower and component lifetimes. */
|
|
4
5
|
export declare class BrowserTerminal {
|
|
5
6
|
private readonly handle;
|
|
@@ -12,6 +13,7 @@ export declare class BrowserTerminal {
|
|
|
12
13
|
private operations;
|
|
13
14
|
private readonly drained;
|
|
14
15
|
private closing;
|
|
16
|
+
private retention;
|
|
15
17
|
private controller;
|
|
16
18
|
/**
|
|
17
19
|
* @param handle - allocated terminal process range.
|
|
@@ -20,6 +22,20 @@ export declare class BrowserTerminal {
|
|
|
20
22
|
* @param maxBufferedBytes - per-follower queue cap.
|
|
21
23
|
*/
|
|
22
24
|
constructor(handle: SubprocessTerminalHandle, info: WebTerminalInfo, scrollback: number, maxBufferedBytes: number);
|
|
25
|
+
/**
|
|
26
|
+
* Start monitoring after this allocation is committed to its Session owner.
|
|
27
|
+
* @param policy - validated Host timing policy.
|
|
28
|
+
* @param closing - closes the id before any asynchronous termination.
|
|
29
|
+
* @param closed - removes the exact successfully terminated owner record.
|
|
30
|
+
* @param failed - diagnostic sink for background cleanup failure.
|
|
31
|
+
*/
|
|
32
|
+
monitor(policy: TerminalRetentionPolicy, closing: () => void, closed: () => void, failed: (error: unknown) => void): void;
|
|
33
|
+
/**
|
|
34
|
+
* Retain this committed process independently of output attachment.
|
|
35
|
+
* @param signal - physical window stream lifetime.
|
|
36
|
+
* @returns its hold acknowledgement and lifetime.
|
|
37
|
+
*/
|
|
38
|
+
retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>;
|
|
23
39
|
/**
|
|
24
40
|
* Attach with exclusive input control; an older attachment becomes read-only.
|
|
25
41
|
* @param id - browser attachment identity.
|
|
@@ -52,6 +68,12 @@ export declare class BrowserTerminal {
|
|
|
52
68
|
* @returns after process cleanup and final output drainage; failures remain retryable.
|
|
53
69
|
*/
|
|
54
70
|
close(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Stop unattended cleanup scheduling and await final process cleanup.
|
|
73
|
+
* @returns after terminal and monitor quiescence.
|
|
74
|
+
*/
|
|
75
|
+
dispose(): Promise<void>;
|
|
76
|
+
private closeProcess;
|
|
55
77
|
private requireController;
|
|
56
78
|
private broadcast;
|
|
57
79
|
private enqueue;
|
package/lib/types/terminal.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
/** One PTY, a bounded terminal emulator and its detachable browser followers. */
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
2
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol';
|
|
3
|
+
import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require';
|
|
4
4
|
import { TerminalFollower } from "./stream.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
|
-
const { Terminal } = require('@xterm/headless');
|
|
10
|
-
const { SerializeAddon } = require('@xterm/addon-serialize');
|
|
11
|
-
return { Terminal, SerializeAddon };
|
|
12
|
-
}
|
|
5
|
+
import { TerminalRetention } from "./retention.js";
|
|
6
|
+
const requireHeadless = createLazyRequire('@xterm/headless', import.meta.url);
|
|
7
|
+
const requireSerialize = createLazyRequire('@xterm/addon-serialize', import.meta.url);
|
|
13
8
|
/** Process lifetime is independent of follower and component lifetimes. */
|
|
14
9
|
export class BrowserTerminal {
|
|
15
10
|
handle;
|
|
@@ -22,6 +17,7 @@ export class BrowserTerminal {
|
|
|
22
17
|
operations = Promise.resolve();
|
|
23
18
|
drained;
|
|
24
19
|
closing;
|
|
20
|
+
retention;
|
|
25
21
|
controller;
|
|
26
22
|
/**
|
|
27
23
|
* @param handle - allocated terminal process range.
|
|
@@ -33,11 +29,37 @@ export class BrowserTerminal {
|
|
|
33
29
|
this.handle = handle;
|
|
34
30
|
this.info = info;
|
|
35
31
|
this.maxBufferedBytes = maxBufferedBytes;
|
|
32
|
+
const { Terminal } = requireHeadless();
|
|
33
|
+
const { SerializeAddon } = requireSerialize();
|
|
36
34
|
this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true });
|
|
37
35
|
this.serializer = new SerializeAddon();
|
|
38
36
|
this.screen.loadAddon(this.serializer);
|
|
39
37
|
this.drained = this.consume();
|
|
40
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Start monitoring after this allocation is committed to its Session owner.
|
|
41
|
+
* @param policy - validated Host timing policy.
|
|
42
|
+
* @param closing - closes the id before any asynchronous termination.
|
|
43
|
+
* @param closed - removes the exact successfully terminated owner record.
|
|
44
|
+
* @param failed - diagnostic sink for background cleanup failure.
|
|
45
|
+
*/
|
|
46
|
+
monitor(policy, closing, closed, failed) {
|
|
47
|
+
this.retention = new TerminalRetention(policy, () => this.handle.inspectActivity(), async () => {
|
|
48
|
+
closing();
|
|
49
|
+
await this.closeProcess();
|
|
50
|
+
closed();
|
|
51
|
+
}, failed);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Retain this committed process independently of output attachment.
|
|
55
|
+
* @param signal - physical window stream lifetime.
|
|
56
|
+
* @returns its hold acknowledgement and lifetime.
|
|
57
|
+
*/
|
|
58
|
+
retain(signal) {
|
|
59
|
+
if (this.retention === undefined)
|
|
60
|
+
throw new Error('Terminal has not been committed');
|
|
61
|
+
return this.retention.retain(signal);
|
|
62
|
+
}
|
|
41
63
|
/**
|
|
42
64
|
* Attach with exclusive input control; an older attachment becomes read-only.
|
|
43
65
|
* @param id - browser attachment identity.
|
|
@@ -78,6 +100,7 @@ export class BrowserTerminal {
|
|
|
78
100
|
* @returns when the provider accepts the input.
|
|
79
101
|
*/
|
|
80
102
|
write(id, data) {
|
|
103
|
+
this.retention?.invalidate();
|
|
81
104
|
return this.enqueue(async () => { this.requireController(id); await this.handle.write(data); });
|
|
82
105
|
}
|
|
83
106
|
/**
|
|
@@ -109,6 +132,14 @@ export class BrowserTerminal {
|
|
|
109
132
|
* @returns after process cleanup and final output drainage; failures remain retryable.
|
|
110
133
|
*/
|
|
111
134
|
close() {
|
|
135
|
+
return this.retention?.close() ?? this.closeProcess();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Stop unattended cleanup scheduling and await final process cleanup.
|
|
139
|
+
* @returns after terminal and monitor quiescence.
|
|
140
|
+
*/
|
|
141
|
+
dispose() { return this.retention?.dispose() ?? this.closeProcess(); }
|
|
142
|
+
closeProcess() {
|
|
112
143
|
if (this.closing !== undefined)
|
|
113
144
|
return this.closing;
|
|
114
145
|
this.closing = (async () => {
|
package/lib/types/types.d.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
3
3
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
4
4
|
interface RemoteErrorDetailsMap {
|
|
5
|
+
/** The terminal identity is missing or has begun process cleanup. */
|
|
6
|
+
'terminal/unavailable': Record<string, never>;
|
|
5
7
|
/** Input or resize was refused without invalidating the output attachment. */
|
|
6
8
|
'terminal/control-unavailable': {
|
|
7
9
|
readonly reason: 'read-only' | 'not-running';
|
|
@@ -16,6 +18,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
16
18
|
export type WebTerminalId = Branded<'WebTerminalId'>;
|
|
17
19
|
/** An attachment allowed to write and resize one terminal. */
|
|
18
20
|
export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>;
|
|
21
|
+
/** Acknowledges one physical window hold without taking screen or input control. */
|
|
22
|
+
export interface TerminalRetentionFrame {
|
|
23
|
+
readonly type: 'retained';
|
|
24
|
+
}
|
|
19
25
|
/** An executable shell verified in the subprocess provider's execution environment. */
|
|
20
26
|
export interface TerminalShell {
|
|
21
27
|
readonly path: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-api-terminal-controller",
|
|
3
3
|
"description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7-alpha.1",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -53,28 +53,30 @@
|
|
|
53
53
|
"@xterm/headless": "^6.0.0",
|
|
54
54
|
"@xterm/addon-serialize": "^0.14.0",
|
|
55
55
|
"zod": "^4.4.3",
|
|
56
|
-
"@deepseek-ai/dsh-
|
|
57
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
58
|
-
"@deepseek-ai/
|
|
56
|
+
"@deepseek-ai/dsh-lazy-require": "^0.1.7-alpha.1",
|
|
57
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-alpha.1",
|
|
58
|
+
"@deepseek-ai/dsh-deque": "^0.1.7-alpha.1",
|
|
59
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.
|
|
62
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
62
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.7-alpha.1",
|
|
63
|
+
"@deepseek-ai/cordis": "^4.0.3"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
66
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-client-store": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-fs": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-
|
|
72
|
-
"@deepseek-ai/dsh-sandbox
|
|
73
|
-
"@deepseek-ai/dsh-
|
|
74
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.
|
|
66
|
+
"@deepseek-ai/cordis": "^4.0.3",
|
|
67
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-alpha.1",
|
|
68
|
+
"@deepseek-ai/dsh-brand": "^0.1.7-alpha.1",
|
|
69
|
+
"@deepseek-ai/dsh-api-gateway": "^0.1.7-alpha.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-store": "^0.1.7-alpha.1",
|
|
71
|
+
"@deepseek-ai/dsh-fs": "^0.1.7-alpha.1",
|
|
72
|
+
"@deepseek-ai/dsh-remote-mock": "^0.1.7-alpha.1",
|
|
73
|
+
"@deepseek-ai/dsh-sandbox": "^0.1.7-alpha.1",
|
|
74
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.1.7-alpha.1",
|
|
75
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.7-alpha.1",
|
|
76
|
+
"@deepseek-ai/dsh-session": "^0.1.7-alpha.1",
|
|
77
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.7-alpha.1",
|
|
78
|
+
"@deepseek-ai/dsh-subprocess-local": "^0.1.7-alpha.1",
|
|
79
|
+
"@deepseek-ai/dsh-util-crypto": "^0.1.7-alpha.1"
|
|
78
80
|
},
|
|
79
81
|
"files": [
|
|
80
82
|
"lib/index.js",
|