@linkshell/gateway 0.2.46 → 0.2.48

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.
Files changed (39) hide show
  1. package/dist/gateway/src/agent-permission-http.d.ts +18 -9
  2. package/dist/gateway/src/agent-permission-http.js +18 -10
  3. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  4. package/dist/gateway/src/embedded.js +119 -55
  5. package/dist/gateway/src/embedded.js.map +1 -1
  6. package/dist/gateway/src/index.js +158 -91
  7. package/dist/gateway/src/index.js.map +1 -1
  8. package/dist/gateway/src/pairings.d.ts +3 -3
  9. package/dist/gateway/src/pairings.js +4 -5
  10. package/dist/gateway/src/pairings.js.map +1 -1
  11. package/dist/gateway/src/relay.d.ts +1 -1
  12. package/dist/gateway/src/relay.js +25 -18
  13. package/dist/gateway/src/relay.js.map +1 -1
  14. package/dist/gateway/src/sessions.d.ts +35 -28
  15. package/dist/gateway/src/sessions.js +165 -145
  16. package/dist/gateway/src/sessions.js.map +1 -1
  17. package/dist/gateway/src/state-store.d.ts +9 -6
  18. package/dist/gateway/src/state-store.js +26 -19
  19. package/dist/gateway/src/state-store.js.map +1 -1
  20. package/dist/gateway/src/tokens.d.ts +27 -7
  21. package/dist/gateway/src/tokens.js +86 -60
  22. package/dist/gateway/src/tokens.js.map +1 -1
  23. package/dist/gateway/src/tunnel.d.ts +11 -10
  24. package/dist/gateway/src/tunnel.js +46 -35
  25. package/dist/gateway/src/tunnel.js.map +1 -1
  26. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  27. package/dist/shared-protocol/src/index.d.ts +662 -494
  28. package/dist/shared-protocol/src/index.js +52 -15
  29. package/dist/shared-protocol/src/index.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/agent-permission-http.ts +18 -10
  32. package/src/embedded.ts +122 -54
  33. package/src/index.ts +162 -91
  34. package/src/pairings.ts +6 -7
  35. package/src/relay.ts +28 -20
  36. package/src/sessions.ts +179 -150
  37. package/src/state-store.ts +41 -25
  38. package/src/tokens.ts +109 -63
  39. package/src/tunnel.ts +57 -39
package/src/relay.ts CHANGED
@@ -20,7 +20,7 @@ export function handleSocketMessage(
20
20
  socket: WebSocket,
21
21
  raw: string,
22
22
  role: "host" | "client",
23
- sessionId: string,
23
+ hostDeviceId: string,
24
24
  deviceId: string,
25
25
  sessions: SessionManager,
26
26
  ): void {
@@ -28,23 +28,23 @@ export function handleSocketMessage(
28
28
  try {
29
29
  envelope = parseEnvelope(raw);
30
30
  } catch {
31
- sendSessionError(socket, sessionId, "invalid_message", "Failed to parse envelope");
31
+ sendSessionError(socket, hostDeviceId, "invalid_message", "Failed to parse envelope");
32
32
  return;
33
33
  }
34
34
 
35
- if (envelope.sessionId !== sessionId) {
35
+ if (envelope.hostDeviceId !== hostDeviceId) {
36
36
  sendSessionError(
37
37
  socket,
38
- sessionId,
38
+ hostDeviceId,
39
39
  "invalid_message",
40
- "Envelope sessionId does not match connection sessionId",
40
+ "Envelope hostDeviceId does not match connection hostDeviceId",
41
41
  );
42
42
  return;
43
43
  }
44
44
 
45
- const session = sessions.get(sessionId);
45
+ const session = sessions.get(hostDeviceId);
46
46
  if (!session) {
47
- sendSessionError(socket, sessionId, "session_not_found", "Session not found");
47
+ sendSessionError(socket, hostDeviceId, "device_not_found", "Device not found");
48
48
  return;
49
49
  }
50
50
 
@@ -65,13 +65,13 @@ export function handleSocketMessage(
65
65
  if (error instanceof ZodError) {
66
66
  sendSessionError(
67
67
  socket,
68
- sessionId,
68
+ hostDeviceId,
69
69
  "invalid_message",
70
70
  error.errors[0]?.message ?? "Invalid message payload",
71
71
  );
72
72
  return;
73
73
  }
74
- sendSessionError(socket, sessionId, "invalid_message", "Failed to handle message");
74
+ sendSessionError(socket, hostDeviceId, "invalid_message", "Failed to handle message");
75
75
  }
76
76
  }
77
77
 
@@ -81,7 +81,7 @@ function isProtocolMessageType(type: string): type is ProtocolMessageType {
81
81
 
82
82
  function sendSessionError(
83
83
  socket: WebSocket,
84
- sessionId: string,
84
+ hostDeviceId: string,
85
85
  code: string,
86
86
  message: string,
87
87
  ): void {
@@ -89,8 +89,8 @@ function sendSessionError(
89
89
  socket.send(
90
90
  serializeEnvelope(
91
91
  createEnvelope({
92
- type: "session.error",
93
- sessionId,
92
+ type: "device.error",
93
+ hostDeviceId,
94
94
  payload: { code, message },
95
95
  }),
96
96
  ),
@@ -103,18 +103,20 @@ function handleHostMessage(
103
103
  sessions: SessionManager,
104
104
  ): void {
105
105
  switch (envelope.type) {
106
+ case "device.connect":
106
107
  case "session.connect": {
107
108
  // Extract metadata from host's connect message
108
109
  const p = parseTypedPayload("session.connect", envelope.payload);
109
- if (p.provider || p.machineId || p.hostname || p.platform || p.cwd || p.projectName) {
110
+ if (p.machineId || p.hostname || p.platform || p.cwd || p.capabilities) {
110
111
  sessions.setMetadata(
111
112
  session.id,
112
- p.provider ?? undefined,
113
+ undefined,
113
114
  p.machineId ?? undefined,
114
115
  p.hostname ?? undefined,
115
116
  p.platform ?? undefined,
116
117
  p.cwd ?? undefined,
117
- p.projectName ?? undefined,
118
+ undefined,
119
+ p.capabilities ?? undefined,
118
120
  );
119
121
  }
120
122
  break;
@@ -129,6 +131,7 @@ function handleHostMessage(
129
131
  broadcastToClients(session, envelope);
130
132
  break;
131
133
  }
134
+ case "device.heartbeat":
132
135
  case "session.heartbeat":
133
136
  break;
134
137
  case "permission.decision.result": {
@@ -187,6 +190,7 @@ function handleHostMessage(
187
190
  case "terminal.spawned":
188
191
  case "terminal.list":
189
192
  case "terminal.browse.result":
193
+ case "terminal.file.read.result":
190
194
  broadcastToClients(session, envelope);
191
195
  break;
192
196
  // Structured status from hooks
@@ -212,8 +216,8 @@ function handleClientMessage(
212
216
  socket.send(
213
217
  serializeEnvelope(
214
218
  createEnvelope({
215
- type: "session.error",
216
- sessionId: session.id,
219
+ type: "device.error",
220
+ hostDeviceId: session.id,
217
221
  payload: {
218
222
  code: "control_conflict",
219
223
  message: "Not the controller",
@@ -235,11 +239,13 @@ function handleClientMessage(
235
239
  sendToHost(session, envelope);
236
240
  break;
237
241
  }
242
+ case "device.ack":
238
243
  case "session.ack": {
239
244
  // Forward ACK to host
240
245
  sendToHost(session, envelope);
241
246
  break;
242
247
  }
248
+ case "device.resume":
243
249
  case "session.resume": {
244
250
  const p = parseTypedPayload("session.resume", envelope.payload);
245
251
  // Replay from gateway buffer first
@@ -254,7 +260,7 @@ function handleClientMessage(
254
260
  serializeEnvelope(
255
261
  createEnvelope({
256
262
  type: "terminal.output",
257
- sessionId: session.id,
263
+ hostDeviceId: session.id,
258
264
  terminalId: msg.terminalId,
259
265
  seq: msg.seq,
260
266
  payload: { ...payload, isReplay: true },
@@ -283,7 +289,7 @@ function handleClientMessage(
283
289
  sessions.claimControl(session.id, deviceId);
284
290
  const grantMsg = createEnvelope({
285
291
  type: "control.grant",
286
- sessionId: session.id,
292
+ hostDeviceId: session.id,
287
293
  payload: { deviceId },
288
294
  });
289
295
  // Broadcast to ALL clients so previous controller updates its state
@@ -295,13 +301,14 @@ function handleClientMessage(
295
301
  sessions.releaseControl(session.id, deviceId);
296
302
  const releaseMsg = createEnvelope({
297
303
  type: "control.release",
298
- sessionId: session.id,
304
+ hostDeviceId: session.id,
299
305
  payload: { deviceId },
300
306
  });
301
307
  broadcastToClients(session, releaseMsg);
302
308
  sendToHost(session, releaseMsg);
303
309
  break;
304
310
  }
311
+ case "device.heartbeat":
305
312
  case "session.heartbeat":
306
313
  break;
307
314
  // Screen sharing: client → host
@@ -325,6 +332,7 @@ function handleClientMessage(
325
332
  case "terminal.kill":
326
333
  case "terminal.list":
327
334
  case "terminal.browse":
335
+ case "terminal.file.read":
328
336
  case "terminal.mkdir":
329
337
  case "terminal.history.request":
330
338
  case "file.upload":
package/src/sessions.ts CHANGED
@@ -1,54 +1,58 @@
1
1
  import type WebSocket from "ws";
2
2
  import type { Envelope } from "@linkshell/protocol";
3
3
 
4
- export type SessionState = "active" | "host_disconnected" | "terminated";
4
+ export type DeviceState = "active" | "host_disconnected" | "terminated";
5
+ export type SessionState = DeviceState;
5
6
 
6
7
  export interface ConnectedDevice {
7
8
  socket: WebSocket;
8
9
  role: "host" | "client";
9
10
  deviceId: string;
11
+ token?: string;
12
+ authorizationId?: string;
10
13
  connectedAt: number;
11
14
  }
12
15
 
13
- export interface Session {
16
+ export interface HostDevice {
14
17
  id: string;
15
- state: SessionState;
18
+ hostDeviceId: string;
19
+ state: DeviceState;
16
20
  host: ConnectedDevice | undefined;
17
21
  clients: Map<string, ConnectedDevice>;
18
22
  controllerId: string | undefined;
19
23
  lastActivity: number;
20
24
  createdAt: number;
21
- outputBuffers: Map<string, Envelope[]>; // keyed by terminalId
22
- lastStatusByTerminal: Map<string, Envelope>; // last terminal.status per terminal
25
+ outputBuffers: Map<string, Envelope[]>;
26
+ lastStatusByTerminal: Map<string, Envelope>;
23
27
  hostDisconnectedAt: number | undefined;
24
- // Metadata from host's session.connect
25
- provider: string | undefined;
26
28
  machineId: string | undefined;
27
29
  hostname: string | undefined;
28
30
  platform: string | undefined;
29
31
  cwd: string | undefined;
30
- projectName: string | undefined;
31
- // Auth: user who owns this session (set on AUTH_REQUIRED gateways)
32
+ capabilities: string[];
32
33
  userId: string | undefined;
33
34
  }
34
35
 
36
+ export type Session = HostDevice;
37
+
35
38
  const OUTPUT_BUFFER_CAPACITY = 200;
36
- const HOST_RECONNECT_WINDOW = 60_000; // 60s
39
+ const HOST_RECONNECT_WINDOW = 60_000;
37
40
  const CLEANUP_INTERVAL = 30_000;
38
41
 
39
- export class SessionManager {
40
- private sessions = new Map<string, Session>();
42
+ export class DeviceManager {
43
+ private devices = new Map<string, HostDevice>();
41
44
  private cleanupTimer: ReturnType<typeof setInterval>;
42
45
 
43
46
  constructor() {
44
47
  this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
45
48
  }
46
49
 
47
- getOrCreate(sessionId: string): Session {
48
- let session = this.sessions.get(sessionId);
49
- if (!session) {
50
- session = {
51
- id: sessionId,
50
+ getOrCreate(hostDeviceId: string): HostDevice {
51
+ let device = this.devices.get(hostDeviceId);
52
+ if (!device) {
53
+ device = {
54
+ id: hostDeviceId,
55
+ hostDeviceId,
52
56
  state: "active",
53
57
  host: undefined,
54
58
  clients: new Map(),
@@ -58,119 +62,142 @@ export class SessionManager {
58
62
  outputBuffers: new Map(),
59
63
  lastStatusByTerminal: new Map(),
60
64
  hostDisconnectedAt: undefined,
61
- provider: undefined,
62
65
  machineId: undefined,
63
66
  hostname: undefined,
64
67
  platform: undefined,
65
68
  cwd: undefined,
66
- projectName: undefined,
69
+ capabilities: [],
67
70
  userId: undefined,
68
71
  };
69
- this.sessions.set(sessionId, session);
72
+ this.devices.set(hostDeviceId, device);
70
73
  }
71
- return session;
74
+ return device;
72
75
  }
73
76
 
74
- get(sessionId: string): Session | undefined {
75
- return this.sessions.get(sessionId);
77
+ get(hostDeviceId: string): HostDevice | undefined {
78
+ return this.devices.get(hostDeviceId);
76
79
  }
77
80
 
78
- setHost(sessionId: string, device: ConnectedDevice): void {
79
- const session = this.getOrCreate(sessionId);
80
- session.host = device;
81
- session.state = "active";
82
- session.hostDisconnectedAt = undefined;
83
- session.lastActivity = Date.now();
81
+ setHost(hostDeviceId: string, socketDevice: ConnectedDevice): void {
82
+ const device = this.getOrCreate(hostDeviceId);
83
+ device.host = socketDevice;
84
+ device.state = "active";
85
+ device.hostDisconnectedAt = undefined;
86
+ device.lastActivity = Date.now();
84
87
  }
85
88
 
86
- addClient(sessionId: string, device: ConnectedDevice): void {
87
- const session = this.getOrCreate(sessionId);
88
- session.clients.set(device.deviceId, device);
89
- if (!session.controllerId) {
90
- session.controllerId = device.deviceId;
89
+ addClient(hostDeviceId: string, socketDevice: ConnectedDevice): void {
90
+ const device = this.getOrCreate(hostDeviceId);
91
+ device.clients.set(socketDevice.deviceId, socketDevice);
92
+ if (!device.controllerId) {
93
+ device.controllerId = socketDevice.deviceId;
91
94
  }
92
- session.lastActivity = Date.now();
95
+ device.lastActivity = Date.now();
93
96
  }
94
97
 
95
98
  removeHost(
96
- sessionId: string,
99
+ hostDeviceId: string,
97
100
  ): { clients: Map<string, ConnectedDevice> } | undefined {
98
- const session = this.sessions.get(sessionId);
99
- if (!session) return undefined;
100
- session.host = undefined;
101
- session.state = "host_disconnected";
102
- session.hostDisconnectedAt = Date.now();
103
- return { clients: session.clients };
101
+ const device = this.devices.get(hostDeviceId);
102
+ if (!device) return undefined;
103
+ device.host = undefined;
104
+ device.state = "host_disconnected";
105
+ device.hostDisconnectedAt = Date.now();
106
+ return { clients: device.clients };
107
+ }
108
+
109
+ removeClient(hostDeviceId: string, deviceId: string): void {
110
+ const device = this.devices.get(hostDeviceId);
111
+ if (!device) return;
112
+ device.clients.delete(deviceId);
113
+ if (device.controllerId === deviceId) {
114
+ const next = device.clients.keys().next();
115
+ device.controllerId = next.done ? undefined : next.value;
116
+ }
117
+ this.maybeDelete(hostDeviceId);
104
118
  }
105
119
 
106
- removeClient(sessionId: string, deviceId: string): void {
107
- const session = this.sessions.get(sessionId);
108
- if (!session) return;
109
- session.clients.delete(deviceId);
110
- if (session.controllerId === deviceId) {
111
- // Transfer control to next client or clear
112
- const next = session.clients.keys().next();
113
- session.controllerId = next.done ? undefined : next.value;
120
+ disconnectAuthorization(hostDeviceId: string, authorizationId: string): number {
121
+ const device = this.devices.get(hostDeviceId);
122
+ if (!device) return 0;
123
+ let closed = 0;
124
+ for (const [deviceId, client] of device.clients) {
125
+ if (client.authorizationId !== authorizationId) continue;
126
+ try {
127
+ client.socket.close(4001, "authorization revoked");
128
+ } catch {}
129
+ device.clients.delete(deviceId);
130
+ closed++;
131
+ }
132
+ if (device.controllerId && !device.clients.has(device.controllerId)) {
133
+ const next = device.clients.keys().next();
134
+ device.controllerId = next.done ? undefined : next.value;
114
135
  }
115
- this.maybeDelete(sessionId);
136
+ this.maybeDelete(hostDeviceId);
137
+ return closed;
116
138
  }
117
139
 
118
- /** Force-delete a session: disconnect host + all clients, remove from map. */
119
- forceDelete(sessionId: string): boolean {
120
- const session = this.sessions.get(sessionId);
121
- if (!session) return false;
122
- if (session.host) {
123
- try { session.host.socket.close(1000, "session deleted"); } catch {}
140
+ forceDelete(hostDeviceId: string): boolean {
141
+ const device = this.devices.get(hostDeviceId);
142
+ if (!device) return false;
143
+ if (device.host) {
144
+ try {
145
+ device.host.socket.close(1000, "device deleted");
146
+ } catch {}
124
147
  }
125
- for (const [, client] of session.clients) {
126
- try { client.socket.close(1000, "session deleted"); } catch {}
148
+ for (const [, client] of device.clients) {
149
+ try {
150
+ client.socket.close(1000, "device deleted");
151
+ } catch {}
127
152
  }
128
- this.sessions.delete(sessionId);
153
+ this.devices.delete(hostDeviceId);
129
154
  return true;
130
155
  }
131
156
 
132
- bufferOutput(sessionId: string, envelope: Envelope): void {
133
- const session = this.sessions.get(sessionId);
134
- if (!session) return;
135
- const tid = (envelope as any).terminalId ?? "default";
136
- let buf = session.outputBuffers.get(tid);
137
- if (!buf) {
138
- buf = [];
139
- session.outputBuffers.set(tid, buf);
157
+ bufferOutput(hostDeviceId: string, envelope: Envelope): void {
158
+ const device = this.devices.get(hostDeviceId);
159
+ if (!device) return;
160
+ const terminalId = envelope.terminalId ?? "default";
161
+ let buffer = device.outputBuffers.get(terminalId);
162
+ if (!buffer) {
163
+ buffer = [];
164
+ device.outputBuffers.set(terminalId, buffer);
140
165
  }
141
- buf.push(envelope);
142
- if (buf.length > OUTPUT_BUFFER_CAPACITY) {
143
- buf.shift();
166
+ buffer.push(envelope);
167
+ if (buffer.length > OUTPUT_BUFFER_CAPACITY) {
168
+ buffer.shift();
144
169
  }
145
- session.lastActivity = Date.now();
170
+ device.lastActivity = Date.now();
146
171
  }
147
172
 
148
- cacheStatus(sessionId: string, envelope: Envelope): void {
149
- const session = this.sessions.get(sessionId);
150
- if (!session) return;
151
- const tid = (envelope as any).terminalId ?? "default";
152
- session.lastStatusByTerminal.set(tid, envelope);
153
- session.lastActivity = Date.now();
173
+ cacheStatus(hostDeviceId: string, envelope: Envelope): void {
174
+ const device = this.devices.get(hostDeviceId);
175
+ if (!device) return;
176
+ const terminalId = envelope.terminalId ?? "default";
177
+ device.lastStatusByTerminal.set(terminalId, envelope);
178
+ device.lastActivity = Date.now();
154
179
  }
155
180
 
156
- getStatusReplay(sessionId: string): Envelope[] {
157
- const session = this.sessions.get(sessionId);
158
- if (!session) return [];
159
- return [...session.lastStatusByTerminal.values()];
181
+ getStatusReplay(hostDeviceId: string): Envelope[] {
182
+ const device = this.devices.get(hostDeviceId);
183
+ if (!device) return [];
184
+ return [...device.lastStatusByTerminal.values()];
160
185
  }
161
186
 
162
187
  getReplayFrom(
163
- sessionId: string,
188
+ hostDeviceId: string,
164
189
  afterSeqByTerminal: Record<string, number>,
165
190
  fallbackAfterSeq = -1,
166
191
  ): Envelope[] {
167
- const session = this.sessions.get(sessionId);
168
- if (!session) return [];
192
+ const device = this.devices.get(hostDeviceId);
193
+ if (!device) return [];
169
194
  const result: Envelope[] = [];
170
- for (const [terminalId, buf] of session.outputBuffers) {
195
+ for (const [terminalId, buffer] of device.outputBuffers) {
171
196
  const afterSeq = afterSeqByTerminal[terminalId] ?? fallbackAfterSeq;
172
- for (const e of buf) {
173
- if (e.seq !== undefined && e.seq > afterSeq) result.push(e);
197
+ for (const envelope of buffer) {
198
+ if (envelope.seq !== undefined && envelope.seq > afterSeq) {
199
+ result.push(envelope);
200
+ }
174
201
  }
175
202
  }
176
203
  return result.sort((a, b) => {
@@ -181,101 +208,101 @@ export class SessionManager {
181
208
  });
182
209
  }
183
210
 
184
- claimControl(sessionId: string, deviceId: string): boolean {
185
- const session = this.sessions.get(sessionId);
186
- if (!session) return false;
187
- // Always allow takeover – last claimer wins
188
- session.controllerId = deviceId;
211
+ claimControl(hostDeviceId: string, deviceId: string): boolean {
212
+ const device = this.devices.get(hostDeviceId);
213
+ if (!device) return false;
214
+ device.controllerId = deviceId;
189
215
  return true;
190
216
  }
191
217
 
192
- releaseControl(sessionId: string, deviceId: string): boolean {
193
- const session = this.sessions.get(sessionId);
194
- if (!session) return false;
195
- if (session.controllerId !== deviceId) return false;
196
- session.controllerId = undefined;
218
+ releaseControl(hostDeviceId: string, deviceId: string): boolean {
219
+ const device = this.devices.get(hostDeviceId);
220
+ if (!device) return false;
221
+ if (device.controllerId !== deviceId) return false;
222
+ device.controllerId = undefined;
197
223
  return true;
198
224
  }
199
225
 
200
- terminate(sessionId: string): void {
201
- const session = this.sessions.get(sessionId);
202
- if (!session) return;
203
- session.state = "terminated";
226
+ terminate(hostDeviceId: string): void {
227
+ const device = this.devices.get(hostDeviceId);
228
+ if (!device) return;
229
+ device.state = "terminated";
204
230
  }
205
231
 
206
- listActive(): Session[] {
207
- return [...this.sessions.values()].filter((s) => s.state !== "terminated");
232
+ listActive(): HostDevice[] {
233
+ return [...this.devices.values()].filter((device) => device.state !== "terminated");
208
234
  }
209
235
 
210
- getSummary(sessionId: string) {
211
- const session = this.sessions.get(sessionId);
212
- if (!session) return undefined;
236
+ getSummary(hostDeviceId: string) {
237
+ const device = this.devices.get(hostDeviceId);
238
+ if (!device) return undefined;
213
239
  return {
214
- id: session.id,
215
- state: session.state,
240
+ id: device.hostDeviceId,
241
+ hostDeviceId: device.hostDeviceId,
242
+ state: device.state,
243
+ online:
244
+ !!device.host &&
245
+ device.host.socket.readyState === device.host.socket.OPEN,
216
246
  hasHost:
217
- !!session.host &&
218
- session.host.socket.readyState === session.host.socket.OPEN,
219
- clientCount: session.clients.size,
220
- controllerId: session.controllerId ?? null,
221
- lastActivity: session.lastActivity,
222
- createdAt: session.createdAt,
223
- bufferSize: [...session.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0),
224
- provider: session.provider ?? null,
225
- machineId: session.machineId ?? null,
226
- hostname: session.hostname ?? null,
227
- platform: session.platform ?? null,
228
- cwd: session.cwd ?? null,
229
- projectName: session.projectName ?? null,
230
- userId: session.userId ?? null,
247
+ !!device.host &&
248
+ device.host.socket.readyState === device.host.socket.OPEN,
249
+ clientCount: device.clients.size,
250
+ controllerId: device.controllerId ?? null,
251
+ lastActivity: device.lastActivity,
252
+ createdAt: device.createdAt,
253
+ bufferSize: [...device.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0),
254
+ machineId: device.machineId ?? null,
255
+ hostname: device.hostname ?? null,
256
+ platform: device.platform ?? null,
257
+ cwd: device.cwd ?? null,
258
+ capabilities: device.capabilities,
259
+ userId: device.userId ?? null,
231
260
  };
232
261
  }
233
262
 
234
263
  setMetadata(
235
- sessionId: string,
236
- provider?: string,
264
+ hostDeviceId: string,
265
+ _provider?: string,
237
266
  machineId?: string,
238
267
  hostname?: string,
239
268
  platform?: string,
240
269
  cwd?: string,
241
- projectName?: string,
270
+ _projectName?: string,
271
+ capabilities?: string[],
242
272
  ): void {
243
- const session = this.sessions.get(sessionId);
244
- if (!session) return;
245
- if (provider) session.provider = provider;
246
- if (machineId) session.machineId = machineId;
247
- if (hostname) session.hostname = hostname;
248
- if (platform) session.platform = platform;
249
- if (cwd) session.cwd = cwd;
250
- if (projectName) session.projectName = projectName;
273
+ const device = this.devices.get(hostDeviceId);
274
+ if (!device) return;
275
+ if (machineId) device.machineId = machineId;
276
+ if (hostname) device.hostname = hostname;
277
+ if (platform) device.platform = platform;
278
+ if (cwd) device.cwd = cwd;
279
+ if (capabilities) device.capabilities = capabilities;
251
280
  }
252
281
 
253
- private maybeDelete(sessionId: string): void {
254
- const session = this.sessions.get(sessionId);
255
- if (!session) return;
256
- if (!session.host && session.clients.size === 0) {
257
- this.sessions.delete(sessionId);
282
+ private maybeDelete(hostDeviceId: string): void {
283
+ const device = this.devices.get(hostDeviceId);
284
+ if (!device) return;
285
+ if (!device.host && device.clients.size === 0) {
286
+ this.devices.delete(hostDeviceId);
258
287
  }
259
288
  }
260
289
 
261
290
  private cleanup(): void {
262
291
  const now = Date.now();
263
- for (const [id, session] of this.sessions) {
264
- // Remove sessions where host disconnected and window expired
292
+ for (const [hostDeviceId, device] of this.devices) {
265
293
  if (
266
- session.state === "host_disconnected" &&
267
- session.hostDisconnectedAt &&
268
- now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
294
+ device.state === "host_disconnected" &&
295
+ device.hostDisconnectedAt &&
296
+ now - device.hostDisconnectedAt > HOST_RECONNECT_WINDOW
269
297
  ) {
270
- session.state = "terminated";
298
+ device.state = "terminated";
271
299
  }
272
- // Clean up terminated sessions with no connections
273
300
  if (
274
- session.state === "terminated" &&
275
- !session.host &&
276
- session.clients.size === 0
301
+ device.state === "terminated" &&
302
+ !device.host &&
303
+ device.clients.size === 0
277
304
  ) {
278
- this.sessions.delete(id);
305
+ this.devices.delete(hostDeviceId);
279
306
  }
280
307
  }
281
308
  }
@@ -284,3 +311,5 @@ export class SessionManager {
284
311
  clearInterval(this.cleanupTimer);
285
312
  }
286
313
  }
314
+
315
+ export class SessionManager extends DeviceManager {}