@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1 → 0.1.6-alpha.2
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 +165 -78
- package/lib/typert.remote-client.d.ts +3 -1
- package/lib/typert.remote-client.js +152 -76
- 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 +17 -16
|
@@ -6,6 +6,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
|
6
6
|
import { randomUUID } from '@deepseek-ai/dsh-util-crypto';
|
|
7
7
|
import { preferredShell, rememberShell } from "./shell-preference.js";
|
|
8
8
|
import { TerminalCloseRequests } from "./close-requests.js";
|
|
9
|
+
import { TerminalWindowHold } from "./retention.js";
|
|
10
|
+
import { TerminalBindings } from "./bindings.js";
|
|
9
11
|
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
|
|
10
12
|
export class ClientTerminals extends Service {
|
|
11
13
|
remote;
|
|
@@ -16,6 +18,10 @@ export class ClientTerminals extends Service {
|
|
|
16
18
|
closed = new Set(this.requests.pending().map(request => request.id));
|
|
17
19
|
disposed = false;
|
|
18
20
|
views = new Map();
|
|
21
|
+
bindings = new TerminalBindings();
|
|
22
|
+
holds = new Map();
|
|
23
|
+
releasing = new Set();
|
|
24
|
+
openTabs = [];
|
|
19
25
|
/**
|
|
20
26
|
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
|
|
21
27
|
* @param remote - generated terminal namespace.
|
|
@@ -27,7 +33,10 @@ export class ClientTerminals extends Service {
|
|
|
27
33
|
this.disposed = true;
|
|
28
34
|
const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()));
|
|
29
35
|
this.views.clear();
|
|
30
|
-
|
|
36
|
+
this.bindings.clear();
|
|
37
|
+
const holds = [...this.holds.values()].flatMap(holds => [...holds.values()].map(hold => hold.dispose()));
|
|
38
|
+
this.holds.clear();
|
|
39
|
+
await Promise.all([...detaching, ...holds, ...this.releasing, ...this.closing.values()]);
|
|
31
40
|
}, 'terminal-controller.client.views');
|
|
32
41
|
for (const request of this.requests.pending())
|
|
33
42
|
this.cleanup(request);
|
|
@@ -36,11 +45,12 @@ export class ClientTerminals extends Service {
|
|
|
36
45
|
* Return the stable model for one sidebar occurrence.
|
|
37
46
|
* @param sessionId - owning Session.
|
|
38
47
|
* @param key - sidebar occurrence key.
|
|
39
|
-
* @param
|
|
48
|
+
* @param contentId - globally unique content identity; layout-local tab ids are not persistence keys.
|
|
49
|
+
* @param terminalId - existing Host identity when restoring a listed terminal; otherwise reuse the saved content identity.
|
|
40
50
|
* @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
|
|
41
51
|
* @returns its observable state and terminal commands.
|
|
42
52
|
*/
|
|
43
|
-
view(sessionId, key, terminalId, shellPath) {
|
|
53
|
+
view(sessionId, key, contentId, terminalId, shellPath) {
|
|
44
54
|
let views = this.views.get(sessionId);
|
|
45
55
|
if (views === undefined) {
|
|
46
56
|
views = new Map();
|
|
@@ -48,9 +58,12 @@ export class ClientTerminals extends Service {
|
|
|
48
58
|
}
|
|
49
59
|
let view = views.get(key);
|
|
50
60
|
if (view === undefined) {
|
|
51
|
-
const
|
|
52
|
-
|
|
61
|
+
const saved = terminalId ?? this.bindings.get(sessionId, contentId);
|
|
62
|
+
const id = saved ?? randomUUID();
|
|
63
|
+
this.bindings.set(sessionId, contentId, id);
|
|
64
|
+
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, saved === undefined, shellPath, signal => this.hold(sessionId, id).ready(signal));
|
|
53
65
|
views.set(key, view);
|
|
66
|
+
this.reconcileHolds();
|
|
54
67
|
void view.refresh();
|
|
55
68
|
}
|
|
56
69
|
return view;
|
|
@@ -77,24 +90,86 @@ export class ClientTerminals extends Service {
|
|
|
77
90
|
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
|
|
78
91
|
* @param sessionId - owning Session.
|
|
79
92
|
* @param key - sidebar occurrence key, including an inactive restored tab.
|
|
93
|
+
* @param contentId - globally unique content identity whose binding is removed.
|
|
80
94
|
* @param terminalId - restored identity if the tab has no model yet.
|
|
81
95
|
*/
|
|
82
|
-
close(sessionId, key, terminalId) {
|
|
96
|
+
close(sessionId, key, contentId, terminalId) {
|
|
83
97
|
const views = this.views.get(sessionId);
|
|
84
98
|
const view = views?.get(key);
|
|
85
|
-
const id = view?.id ?? terminalId;
|
|
99
|
+
const id = view?.id ?? terminalId ?? this.bindings.get(sessionId, contentId);
|
|
86
100
|
if (id === undefined)
|
|
87
101
|
return;
|
|
88
102
|
const request = { sessionId, id, title: view?.state.getSnapshot().title ?? key };
|
|
89
103
|
this.closed.add(id);
|
|
90
104
|
this.requests.save(request);
|
|
105
|
+
this.bindings.delete(sessionId, contentId);
|
|
91
106
|
views?.delete(key);
|
|
92
107
|
if (views?.size === 0)
|
|
93
108
|
this.views.delete(sessionId);
|
|
94
109
|
this.cleanup(request, view);
|
|
110
|
+
this.reconcileHolds();
|
|
95
111
|
}
|
|
96
112
|
/**
|
|
97
|
-
*
|
|
113
|
+
* Reconcile this window's open terminal occurrences, including dormant saved Sessions.
|
|
114
|
+
* @param tabs - terminal-kind membership supplied by the sidebar layout owner.
|
|
115
|
+
*/
|
|
116
|
+
retainTabs(tabs) {
|
|
117
|
+
this.openTabs = tabs;
|
|
118
|
+
this.reconcileHolds();
|
|
119
|
+
}
|
|
120
|
+
hold(sessionId, id) {
|
|
121
|
+
let holds = this.holds.get(sessionId);
|
|
122
|
+
if (holds === undefined) {
|
|
123
|
+
holds = new Map();
|
|
124
|
+
this.holds.set(sessionId, holds);
|
|
125
|
+
}
|
|
126
|
+
let hold = holds.get(id);
|
|
127
|
+
if (hold === undefined || hold.failed) {
|
|
128
|
+
if (hold !== undefined)
|
|
129
|
+
this.release(hold);
|
|
130
|
+
hold = new TerminalWindowHold(this.ctx.remote, this.remote, sessionId, id);
|
|
131
|
+
holds.set(id, hold);
|
|
132
|
+
}
|
|
133
|
+
return hold;
|
|
134
|
+
}
|
|
135
|
+
reconcileHolds() {
|
|
136
|
+
if (this.disposed)
|
|
137
|
+
return;
|
|
138
|
+
const wanted = new Map();
|
|
139
|
+
for (const tab of this.openTabs) {
|
|
140
|
+
const id = this.bindings.get(tab.sessionId, tab.contentId);
|
|
141
|
+
if (id === undefined || this.closed.has(id))
|
|
142
|
+
continue;
|
|
143
|
+
let ids = wanted.get(tab.sessionId);
|
|
144
|
+
if (ids === undefined) {
|
|
145
|
+
ids = new Set();
|
|
146
|
+
wanted.set(tab.sessionId, ids);
|
|
147
|
+
}
|
|
148
|
+
ids.add(id);
|
|
149
|
+
const view = this.views.get(tab.sessionId)?.get(tab.tabId);
|
|
150
|
+
if (view === undefined || view.state.getSnapshot().info !== undefined) {
|
|
151
|
+
if (!this.holds.get(tab.sessionId)?.has(id))
|
|
152
|
+
this.hold(tab.sessionId, id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const [sessionId, holds] of this.holds) {
|
|
156
|
+
for (const [id, hold] of holds) {
|
|
157
|
+
if (!wanted.get(sessionId)?.has(id)) {
|
|
158
|
+
holds.delete(id);
|
|
159
|
+
this.release(hold);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (holds.size === 0)
|
|
163
|
+
this.holds.delete(sessionId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
release(hold) {
|
|
167
|
+
const releasing = hold.dispose().finally(() => { this.releasing.delete(releasing); });
|
|
168
|
+
this.releasing.add(releasing);
|
|
169
|
+
void releasing.catch((error) => { this.ctx.logger.warn('Terminal hold release failed', error); });
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Query Host terminals without a live view or unfinished close.
|
|
98
173
|
* @param sessionId - Session being displayed.
|
|
99
174
|
* @returns terminals available for opening as recovered tabs.
|
|
100
175
|
*/
|
|
@@ -40,6 +40,7 @@ export declare class TerminalView {
|
|
|
40
40
|
readonly id: WebTerminalId;
|
|
41
41
|
private readonly createWhenMissing;
|
|
42
42
|
private readonly shellPath?;
|
|
43
|
+
private readonly retain?;
|
|
43
44
|
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
|
44
45
|
readonly state: SnapshotStore<TerminalViewState>;
|
|
45
46
|
private readonly lifetime;
|
|
@@ -61,8 +62,9 @@ export declare class TerminalView {
|
|
|
61
62
|
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
|
62
63
|
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
|
63
64
|
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
|
65
|
+
* @param retain - window hold acknowledgement required before output attachment.
|
|
64
66
|
*/
|
|
65
|
-
constructor(sessionId: SessionId, remote: TerminalRemote, gateway: Pick<ClientRemote, '$stream'>, id: WebTerminalId, createWhenMissing?: boolean, shellPath?: string | undefined);
|
|
67
|
+
constructor(sessionId: SessionId, remote: TerminalRemote, gateway: Pick<ClientRemote, '$stream'>, id: WebTerminalId, createWhenMissing?: boolean, shellPath?: string | undefined, retain?: ((signal: AbortSignal) => Promise<void>) | undefined);
|
|
66
68
|
/**
|
|
67
69
|
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
|
|
68
70
|
* @returns a detach callback that leaves the terminal process alive.
|
|
@@ -15,6 +15,7 @@ export class TerminalView {
|
|
|
15
15
|
id;
|
|
16
16
|
createWhenMissing;
|
|
17
17
|
shellPath;
|
|
18
|
+
retain;
|
|
18
19
|
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
|
19
20
|
state = createSnapshotStore({ phase: 'idle', writable: false });
|
|
20
21
|
lifetime = new AbortController();
|
|
@@ -36,14 +37,16 @@ export class TerminalView {
|
|
|
36
37
|
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
|
37
38
|
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
|
38
39
|
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
|
40
|
+
* @param retain - window hold acknowledgement required before output attachment.
|
|
39
41
|
*/
|
|
40
|
-
constructor(sessionId, remote, gateway, id, createWhenMissing = true, shellPath) {
|
|
42
|
+
constructor(sessionId, remote, gateway, id, createWhenMissing = true, shellPath, retain) {
|
|
41
43
|
this.sessionId = sessionId;
|
|
42
44
|
this.remote = remote;
|
|
43
45
|
this.gateway = gateway;
|
|
44
46
|
this.id = id;
|
|
45
47
|
this.createWhenMissing = createWhenMissing;
|
|
46
48
|
this.shellPath = shellPath;
|
|
49
|
+
this.retain = retain;
|
|
47
50
|
}
|
|
48
51
|
/**
|
|
49
52
|
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
|
|
@@ -117,8 +120,16 @@ export class TerminalView {
|
|
|
117
120
|
}
|
|
118
121
|
adopt(info) {
|
|
119
122
|
this.patch({ info, title: info.title });
|
|
120
|
-
if (this.
|
|
121
|
-
this.
|
|
123
|
+
if (this.retain === undefined) {
|
|
124
|
+
if (this.mounted && this.closing === undefined)
|
|
125
|
+
this.connect();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
void this.retain(this.lifetime.signal).then(() => {
|
|
129
|
+
if (this.mounted && this.closing === undefined)
|
|
130
|
+
this.connect();
|
|
131
|
+
}).catch((error) => { if (!this.stopped())
|
|
132
|
+
this.fail(error); });
|
|
122
133
|
}
|
|
123
134
|
/** Reattach with a fresh screen and regain input control. */
|
|
124
135
|
connect() {
|
|
@@ -128,11 +139,13 @@ export class TerminalView {
|
|
|
128
139
|
this.detach();
|
|
129
140
|
const stream = this.gateway.$stream({
|
|
130
141
|
name: 'Browser terminal output',
|
|
131
|
-
open: (signal)
|
|
142
|
+
open: async function* (signal) {
|
|
143
|
+
await this.retain?.(signal);
|
|
144
|
+
signal.throwIfAborted();
|
|
132
145
|
const attachmentId = randomUUID();
|
|
133
146
|
this.attachmentId = attachmentId;
|
|
134
|
-
|
|
135
|
-
},
|
|
147
|
+
yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal);
|
|
148
|
+
}.bind(this),
|
|
136
149
|
ended: () => new TerminalViewError('attachmentEnded'),
|
|
137
150
|
carrierFailed: () => { if (this.stream === stream)
|
|
138
151
|
this.patch({ phase: 'disconnected', writable: false }); },
|
|
@@ -311,7 +324,7 @@ export class TerminalView {
|
|
|
311
324
|
this.patch({ writable: false, error: undefined, issue: undefined });
|
|
312
325
|
return;
|
|
313
326
|
}
|
|
314
|
-
const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined;
|
|
327
|
+
const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : failure?.code === 'terminal/unavailable' ? 'missingTerminal' : undefined;
|
|
315
328
|
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) });
|
|
316
329
|
}
|
|
317
330
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** One reconnecting window hold, shared by all occurrences of a terminal. */
|
|
2
|
+
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client';
|
|
3
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
4
|
+
import type { WebTerminalId } from '../types.ts';
|
|
5
|
+
import type { TerminalRemote } from './model.ts';
|
|
6
|
+
/** A stream acknowledgement gates output attachment for each physical connection. */
|
|
7
|
+
export declare class TerminalWindowHold {
|
|
8
|
+
private readonly stream;
|
|
9
|
+
private readonly waiters;
|
|
10
|
+
private generation;
|
|
11
|
+
private failure;
|
|
12
|
+
/**
|
|
13
|
+
* @param gateway - reconnecting stream owner.
|
|
14
|
+
* @param remote - typed terminal namespace.
|
|
15
|
+
* @param sessionId - saved layout's Session, without Agent activation.
|
|
16
|
+
* @param id - existing Host terminal.
|
|
17
|
+
*/
|
|
18
|
+
constructor(gateway: Pick<ClientRemote, '$stream'>, remote: TerminalRemote, sessionId: SessionId, id: WebTerminalId);
|
|
19
|
+
/** Whether a terminal-domain failure ended this hold, allowing an explicit retry. */
|
|
20
|
+
get failed(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Wait for an acknowledged current physical hold before following its screen.
|
|
23
|
+
* @param signal - output request or view lifetime.
|
|
24
|
+
* @returns after acknowledgement, or rejects on cancellation/unavailability.
|
|
25
|
+
*/
|
|
26
|
+
ready(signal: AbortSignal): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Release this window's stream and all acknowledgement waiters.
|
|
29
|
+
* @returns after the stream consumer closes.
|
|
30
|
+
*/
|
|
31
|
+
dispose(): Promise<void>;
|
|
32
|
+
private consume;
|
|
33
|
+
private reject;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=retention.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol';
|
|
2
|
+
/** A stream acknowledgement gates output attachment for each physical connection. */
|
|
3
|
+
export class TerminalWindowHold {
|
|
4
|
+
stream;
|
|
5
|
+
waiters = new Set();
|
|
6
|
+
generation;
|
|
7
|
+
failure;
|
|
8
|
+
/**
|
|
9
|
+
* @param gateway - reconnecting stream owner.
|
|
10
|
+
* @param remote - typed terminal namespace.
|
|
11
|
+
* @param sessionId - saved layout's Session, without Agent activation.
|
|
12
|
+
* @param id - existing Host terminal.
|
|
13
|
+
*/
|
|
14
|
+
constructor(gateway, remote, sessionId, id) {
|
|
15
|
+
this.stream = gateway.$stream({
|
|
16
|
+
name: 'Browser terminal window hold',
|
|
17
|
+
open: signal => remote.retain(sessionId, id, signal),
|
|
18
|
+
ended: () => new RemoteError('terminal/unavailable', 'Terminal hold ended', {}),
|
|
19
|
+
});
|
|
20
|
+
void this.consume();
|
|
21
|
+
}
|
|
22
|
+
/** Whether a terminal-domain failure ended this hold, allowing an explicit retry. */
|
|
23
|
+
get failed() { return this.failure !== undefined; }
|
|
24
|
+
/**
|
|
25
|
+
* Wait for an acknowledged current physical hold before following its screen.
|
|
26
|
+
* @param signal - output request or view lifetime.
|
|
27
|
+
* @returns after acknowledgement, or rejects on cancellation/unavailability.
|
|
28
|
+
*/
|
|
29
|
+
async ready(signal) {
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
if (this.failure !== undefined)
|
|
32
|
+
throw this.failure;
|
|
33
|
+
if (this.generation !== undefined && !this.generation.aborted)
|
|
34
|
+
return;
|
|
35
|
+
const waiting = Promise.withResolvers();
|
|
36
|
+
const abort = () => { waiting.reject(signal.reason); };
|
|
37
|
+
this.waiters.add(waiting);
|
|
38
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
39
|
+
try {
|
|
40
|
+
await waiting.promise;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
this.waiters.delete(waiting);
|
|
44
|
+
signal.removeEventListener('abort', abort);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Release this window's stream and all acknowledgement waiters.
|
|
49
|
+
* @returns after the stream consumer closes.
|
|
50
|
+
*/
|
|
51
|
+
dispose() {
|
|
52
|
+
this.reject(new Error('Terminal window hold released'));
|
|
53
|
+
return this.stream.dispose();
|
|
54
|
+
}
|
|
55
|
+
async consume() {
|
|
56
|
+
try {
|
|
57
|
+
for await (const item of this.stream) {
|
|
58
|
+
item.accept();
|
|
59
|
+
this.generation = item.signal;
|
|
60
|
+
for (const waiter of this.waiters)
|
|
61
|
+
waiter.resolve();
|
|
62
|
+
this.waiters.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
this.reject(error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
reject(error) {
|
|
70
|
+
this.failure = error instanceof Error ? error : new Error('Terminal window hold failed', { cause: error });
|
|
71
|
+
this.generation = undefined;
|
|
72
|
+
for (const waiter of this.waiters)
|
|
73
|
+
waiter.reject(error);
|
|
74
|
+
this.waiters.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=retention.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/** Session-
|
|
1
|
+
/** Session-owned user terminals with the execution environment's system-user permissions. */
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
3
|
import z from '@deepseek-ai/schemastery';
|
|
4
4
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
5
5
|
import type { SessionId } from '@deepseek-ai/dsh-session';
|
|
6
6
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
7
|
-
import type { TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from './types.ts';
|
|
7
|
+
import type { TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, WebTerminalId, WebTerminalInfo } from './types.ts';
|
|
8
8
|
export type * from './types.ts';
|
|
9
9
|
declare module '@deepseek-ai/cordis' {
|
|
10
10
|
interface Context {
|
|
@@ -39,6 +39,12 @@ export interface Config {
|
|
|
39
39
|
readonly maxInputBytes: number;
|
|
40
40
|
/** Provider process-termination grace period in milliseconds. */
|
|
41
41
|
readonly disposeGraceMs: number;
|
|
42
|
+
/** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
|
|
43
|
+
readonly unattendedTimeoutMs: number;
|
|
44
|
+
/** Interval between unattended shell and process observations. */
|
|
45
|
+
readonly activityPollIntervalMs: number;
|
|
46
|
+
/** Delay before retrying failed owned terminal cleanup. */
|
|
47
|
+
readonly cleanupRetryMs: number;
|
|
42
48
|
}
|
|
43
49
|
/** Typed Remote control of transient Session-owned terminal processes. */
|
|
44
50
|
export declare class TerminalController extends TypertRemoteService {
|
|
@@ -73,13 +79,21 @@ export declare class TerminalController extends TypertRemoteService {
|
|
|
73
79
|
*/
|
|
74
80
|
list(sessionId: SessionId): WebTerminalInfo[];
|
|
75
81
|
/**
|
|
76
|
-
* Allocate
|
|
82
|
+
* Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
|
|
77
83
|
* @param agent - Session owner supplied by the Gateway.
|
|
78
84
|
* @param request - initial dimensions and idempotency identity.
|
|
79
85
|
* @param signal - allocation cancellation; committed terminals survive disconnection.
|
|
80
86
|
* @returns the existing or newly committed terminal.
|
|
81
87
|
*/
|
|
82
88
|
create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>;
|
|
89
|
+
/**
|
|
90
|
+
* Retain an existing terminal for a window without activating its Agent or taking input control.
|
|
91
|
+
* @param sessionId - owning Session identity, including an inactive saved layout.
|
|
92
|
+
* @param id - retained Host terminal identity.
|
|
93
|
+
* @param signal - physical Remote stream cancellation.
|
|
94
|
+
* @returns a hold acknowledgement followed by an open lifetime stream.
|
|
95
|
+
*/
|
|
96
|
+
retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>;
|
|
83
97
|
/**
|
|
84
98
|
* Attach to a terminal without binding its process lifetime to the transport.
|
|
85
99
|
* @param agent - Session owner supplied by the Gateway.
|
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');
|