x-shell.js 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +292 -2
  3. package/dist/client/browser-bundle.js +197 -3
  4. package/dist/client/browser-bundle.js.map +2 -2
  5. package/dist/client/index.d.ts +1 -1
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/terminal-client.d.ts +59 -3
  8. package/dist/client/terminal-client.d.ts.map +1 -1
  9. package/dist/client/terminal-client.js +194 -4
  10. package/dist/client/terminal-client.js.map +1 -1
  11. package/dist/server/circular-buffer.d.ts +55 -0
  12. package/dist/server/circular-buffer.d.ts.map +1 -0
  13. package/dist/server/circular-buffer.js +91 -0
  14. package/dist/server/circular-buffer.js.map +1 -0
  15. package/dist/server/index.d.ts +4 -1
  16. package/dist/server/index.d.ts.map +1 -1
  17. package/dist/server/index.js +3 -0
  18. package/dist/server/index.js.map +1 -1
  19. package/dist/server/session-manager.d.ts +195 -0
  20. package/dist/server/session-manager.d.ts.map +1 -0
  21. package/dist/server/session-manager.js +448 -0
  22. package/dist/server/session-manager.js.map +1 -0
  23. package/dist/server/terminal-server.d.ts +54 -6
  24. package/dist/server/terminal-server.d.ts.map +1 -1
  25. package/dist/server/terminal-server.js +313 -80
  26. package/dist/server/terminal-server.js.map +1 -1
  27. package/dist/shared/types.d.ts +122 -2
  28. package/dist/shared/types.d.ts.map +1 -1
  29. package/dist/ui/browser-bundle.js +745 -47
  30. package/dist/ui/browser-bundle.js.map +3 -3
  31. package/dist/ui/x-shell-terminal.d.ts +99 -1
  32. package/dist/ui/x-shell-terminal.d.ts.map +1 -1
  33. package/dist/ui/x-shell-terminal.js +604 -48
  34. package/dist/ui/x-shell-terminal.js.map +1 -1
  35. package/package.json +5 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0] - 2025-01-09
9
+
10
+ ### Added
11
+
12
+ - **Tabbed Terminals**: New `show-tabs` attribute enables multiple terminal tabs in a single component
13
+ - Each tab has independent WebSocket connection and terminal session
14
+ - Tab bar with status indicators and add/close buttons
15
+ - Dynamic labels showing shell or container name
16
+ - `createTab()`, `switchTab()`, `closeTab()` methods
17
+ - **Join Existing Session**: Connection panel now shows "Join Existing Session" mode when sessions are available
18
+ - **Prompt Refresh on Join**: Joining a session now triggers a fresh prompt display
19
+
20
+ ### Fixed
21
+
22
+ - Fixed duplicate output when multiple tabs join the same session
23
+ - Fixed session list not updating after spawning a session
24
+ - Each client's data handler now correctly writes to its own tab's terminal
25
+
26
+ ## [1.0.0] - 2025-01-08
27
+
28
+ ### Added
29
+
30
+ - Initial release
31
+ - WebSocket-based terminal server with node-pty
32
+ - Lightweight client library with auto-reconnection
33
+ - `<x-shell-terminal>` Lit web component with xterm.js
34
+ - Docker exec support for connecting to containers
35
+ - Docker attach mode for connecting to container's main process (PID 1)
36
+ - Session multiplexing - multiple clients sharing the same terminal
37
+ - Session persistence with configurable orphan timeout
38
+ - History replay for clients joining existing sessions
39
+ - Built-in connection panel with container/shell selector
40
+ - Settings dropdown (theme, font size)
41
+ - Status bar with connection info
42
+ - Dark/light/auto theme support
43
+ - Security features: shell, path, and container allowlists
44
+ - Python client bindings (`bindings/python/`)
45
+ - Example projects for Docker containers and multiplexing
package/README.md CHANGED
@@ -9,7 +9,12 @@ A plug-and-play terminal solution for web applications. Includes a server compon
9
9
  - **Server**: WebSocket server with node-pty for real shell sessions
10
10
  - **Client**: Lightweight WebSocket client with auto-reconnection
11
11
  - **UI**: `<x-shell-terminal>` Lit web component with xterm.js
12
- - **Docker**: Connect to Docker containers via `docker exec`
12
+ - **Tabbed Terminals**: Multiple terminal tabs in a single component
13
+ - **Docker Exec**: Connect to Docker containers via `docker exec`
14
+ - **Docker Attach**: Connect to a container's main process (PID 1)
15
+ - **Session Multiplexing**: Multiple clients can share the same terminal session
16
+ - **Session Persistence**: Sessions survive client disconnects with configurable timeout
17
+ - **History Replay**: New clients receive recent terminal output when joining
13
18
  - **Themes**: Built-in dark/light/auto theme support
14
19
  - **Security**: Configurable shell, path, and container allowlists
15
20
  - **Framework Agnostic**: Works with React, Vue, Angular, Svelte, or vanilla JS
@@ -58,7 +63,7 @@ The UI component can be loaded directly from a CDN - no build step required:
58
63
  <script type="module" src="https://cdn.jsdelivr.net/npm/x-shell.js/dist/ui/browser-bundle.js"></script>
59
64
 
60
65
  <!-- Pin to a specific version -->
61
- <script type="module" src="https://unpkg.com/x-shell.js@1.0.0-rc.1/dist/ui/browser-bundle.js"></script>
66
+ <script type="module" src="https://unpkg.com/x-shell.js@1.0.0/dist/ui/browser-bundle.js"></script>
62
67
  ```
63
68
 
64
69
  The bundle includes the `<x-shell-terminal>` web component with xterm.js built-in.
@@ -169,6 +174,13 @@ const server = new TerminalServer({
169
174
 
170
175
  // Enable verbose logging
171
176
  verbose: false,
177
+
178
+ // Session multiplexing options
179
+ maxClientsPerSession: 10, // Max clients per session (default: 10)
180
+ orphanTimeout: 60000, // Ms before orphaned sessions close (default: 60000)
181
+ historySize: 50000, // History buffer size in chars (default: 50000)
182
+ historyEnabled: true, // Enable history replay (default: true)
183
+ maxSessionsTotal: 100, // Max concurrent sessions (default: 100)
172
184
  });
173
185
 
174
186
  // Attach to HTTP server
@@ -235,6 +247,20 @@ client.isConnected(); // boolean
235
247
  client.hasActiveSession(); // boolean
236
248
  client.getSessionId(); // string | null
237
249
  client.getSessionInfo(); // SessionInfo | null
250
+
251
+ // Session multiplexing
252
+ const sessions = await client.listSessions(); // List all sessions
253
+ const session = await client.join({ // Join existing session
254
+ sessionId: 'term-123...',
255
+ requestHistory: true,
256
+ historyLimit: 50000,
257
+ });
258
+ client.leave(); // Leave without killing
259
+
260
+ // Multiplexing event handlers
261
+ client.onClientJoined((sessionId, count) => console.log(`${count} clients`));
262
+ client.onClientLeft((sessionId, count) => console.log(`${count} clients`));
263
+ client.onSessionClosed((sessionId, reason) => console.log(reason));
238
264
  ```
239
265
 
240
266
  ### UI Component
@@ -278,6 +304,7 @@ client.getSessionInfo(); // SessionInfo | null
278
304
  | `show-connection-panel` | boolean | `false` | Show connection panel with container/shell selector |
279
305
  | `show-settings` | boolean | `false` | Show settings dropdown (theme, font size) |
280
306
  | `show-status-bar` | boolean | `false` | Show status bar with connection info and errors |
307
+ | `show-tabs` | boolean | `false` | Enable tabbed terminal interface |
281
308
 
282
309
  **Methods:**
283
310
 
@@ -292,6 +319,11 @@ terminal.clear(); // Clear display
292
319
  terminal.write('text'); // Write to display
293
320
  terminal.writeln('line'); // Write line to display
294
321
  terminal.focus(); // Focus terminal
322
+
323
+ // Tab methods (when show-tabs is enabled)
324
+ terminal.createTab('label'); // Create new tab
325
+ terminal.switchTab('tab-id'); // Switch to tab
326
+ terminal.closeTab('tab-id'); // Close tab
295
327
  ```
296
328
 
297
329
  **Events:**
@@ -328,6 +360,68 @@ The connection panel automatically queries the server for:
328
360
  ></x-shell-terminal>
329
361
  ```
330
362
 
363
+ ## Tabbed Terminals
364
+
365
+ Enable multiple terminal tabs within a single component using the `show-tabs` attribute:
366
+
367
+ ```html
368
+ <x-shell-terminal
369
+ url="ws://localhost:3000/terminal"
370
+ show-tabs
371
+ show-connection-panel
372
+ show-settings
373
+ show-status-bar
374
+ ></x-shell-terminal>
375
+ ```
376
+
377
+ ### Features
378
+
379
+ - **Independent Sessions**: Each tab has its own WebSocket connection and terminal session
380
+ - **Tab Bar**: Shows all open tabs with status indicators
381
+ - **Dynamic Labels**: Tabs automatically update their label to show the shell or container name
382
+ - **Session Joining**: Create a tab and join an existing session from another tab
383
+ - **Easy Management**: Click "+" to add tabs, "×" to close, click tab to switch
384
+
385
+ ### Tab API
386
+
387
+ ```javascript
388
+ const terminal = document.querySelector('x-shell-terminal');
389
+
390
+ // Create a new tab
391
+ const tab = terminal.createTab('My Terminal');
392
+ // Returns: { id: 'tab-1', label: 'My Terminal', ... }
393
+
394
+ // Switch to a specific tab
395
+ terminal.switchTab('tab-1');
396
+
397
+ // Close a tab (resources are cleaned up automatically)
398
+ terminal.closeTab('tab-1');
399
+
400
+ // Access tab state
401
+ // Each tab maintains its own: client, terminal, sessionInfo, etc.
402
+ ```
403
+
404
+ ### Use Cases
405
+
406
+ **1. Multi-Environment Development**
407
+ ```html
408
+ <!-- Open tabs for different containers -->
409
+ <x-shell-terminal show-tabs show-connection-panel></x-shell-terminal>
410
+ ```
411
+ - Tab 1: Local shell for git operations
412
+ - Tab 2: Docker container for backend
413
+ - Tab 3: Docker container for frontend
414
+
415
+ **2. Session Sharing**
416
+ - Create a session in Tab 1
417
+ - Create Tab 2, select "Join Existing Session"
418
+ - Both tabs now mirror the same terminal
419
+
420
+ **3. Monitoring Multiple Processes**
421
+ - Open multiple tabs
422
+ - Each tab connects to a different running session
423
+ - Monitor all processes from a single interface
424
+
331
425
  ## Theming
332
426
 
333
427
  The component uses CSS custom properties for theming:
@@ -436,6 +530,178 @@ const server = new TerminalServer({
436
530
  });
437
531
  ```
438
532
 
533
+ ### Docker Attach Mode
534
+
535
+ Docker attach connects to a container's main process (PID 1) instead of spawning a new shell. This is useful for:
536
+ - Interacting with interactive containers started with `docker run -it`
537
+ - Debugging container startup issues
538
+ - Sharing a session with `docker attach` from another terminal
539
+
540
+ ```javascript
541
+ // Client: Attach to container's main process
542
+ await client.spawn({
543
+ container: 'my-container',
544
+ attachMode: true, // Use docker attach instead of docker exec
545
+ });
546
+ ```
547
+
548
+ **Important:** Docker attach connects to whatever is running as PID 1. If the container was started with a non-interactive command (like a web server), attach may not provide useful interaction.
549
+
550
+ ```bash
551
+ # Start an interactive container first
552
+ docker run -it --name demo alpine sh
553
+
554
+ # Then attach via x-shell
555
+ # Both terminals now share the same shell session
556
+ ```
557
+
558
+ ## Session Multiplexing
559
+
560
+ Session multiplexing allows multiple clients to connect to the same terminal session. This enables:
561
+ - **Collaboration**: Multiple users can share a terminal
562
+ - **Session Persistence**: Sessions survive client disconnects
563
+ - **History Replay**: New clients receive recent output when joining
564
+ - **Monitoring**: Watch others' terminal sessions in real-time
565
+
566
+ ### How It Works
567
+
568
+ ```
569
+ ┌──────────┐ ┌─────────────────────────────────────────┐ ┌──────────┐
570
+ │ Client A │◄────┤ SessionManager ├────►│ PTY │
571
+ └──────────┘ │ ┌─────────────────────────────────────┐ │ │ Process │
572
+ │ │ SharedSession │ │ └──────────┘
573
+ ┌──────────┐ │ │ - clients: [A, B, C] │ │
574
+ │ Client B │◄────┼──┤ - historyBuffer (50KB) │ │
575
+ └──────────┘ │ │ - orphanedAt: null │ │
576
+ │ └─────────────────────────────────────┘ │
577
+ ┌──────────┐ │ │
578
+ │ Client C │◄────┼─────────────────────────────────────────┘
579
+ └──────────┘ (broadcast output)
580
+ ```
581
+
582
+ ### Server Configuration
583
+
584
+ ```javascript
585
+ const server = new TerminalServer({
586
+ // Maximum clients that can join a single session
587
+ maxClientsPerSession: 10,
588
+
589
+ // Time before orphaned session is killed (ms)
590
+ // Set to 0 to kill immediately on last client disconnect
591
+ orphanTimeout: 60000,
592
+
593
+ // History buffer size for replay on join
594
+ historySize: 50000,
595
+
596
+ // Enable/disable history feature
597
+ historyEnabled: true,
598
+
599
+ // Maximum total sessions across all clients
600
+ maxSessionsTotal: 100,
601
+ });
602
+
603
+ // Get session statistics
604
+ const stats = server.getStats();
605
+ // { sessionCount: 5, clientCount: 12, orphanedCount: 1 }
606
+
607
+ // List all sessions with multiplexing info
608
+ const sessions = server.getSharedSessions();
609
+ ```
610
+
611
+ ### Client API
612
+
613
+ ```javascript
614
+ // List available sessions
615
+ const sessions = await client.listSessions();
616
+ // Filter by type or container
617
+ const dockerSessions = await client.listSessions({ type: 'docker-exec' });
618
+
619
+ // Create a shareable session
620
+ await client.spawn({
621
+ shell: '/bin/bash',
622
+ label: 'dev-session', // Optional label for identification
623
+ allowJoin: true, // Allow others to join (default: true)
624
+ enableHistory: true, // Enable history buffer (default: true)
625
+ });
626
+
627
+ // Join an existing session
628
+ const session = await client.join({
629
+ sessionId: 'term-abc123...',
630
+ requestHistory: true, // Request output history
631
+ historyLimit: 50000, // Max history chars to receive
632
+ });
633
+ // session.history contains recent output
634
+
635
+ // Leave session without killing it
636
+ client.leave();
637
+ // Session survives if other clients connected
638
+ // Or waits orphanTimeout before closing
639
+
640
+ // Kill session (only owner can do this)
641
+ client.kill();
642
+ ```
643
+
644
+ ### Spawn Options for Multiplexing
645
+
646
+ | Option | Type | Default | Description |
647
+ |--------|------|---------|-------------|
648
+ | `label` | string | - | Session label for identification |
649
+ | `allowJoin` | boolean | `true` | Allow other clients to join |
650
+ | `enableHistory` | boolean | `true` | Enable history buffer |
651
+ | `attachMode` | boolean | `false` | Use docker attach instead of exec |
652
+
653
+ ### Event Handlers
654
+
655
+ ```javascript
656
+ // Called when another client joins your session
657
+ client.onClientJoined((sessionId, clientCount) => {
658
+ console.log(`Client joined, ${clientCount} total`);
659
+ });
660
+
661
+ // Called when another client leaves
662
+ client.onClientLeft((sessionId, clientCount) => {
663
+ console.log(`Client left, ${clientCount} remaining`);
664
+ });
665
+
666
+ // Called when session is closed
667
+ client.onSessionClosed((sessionId, reason) => {
668
+ // reason: 'orphan_timeout' | 'owner_closed' | 'process_exit' | 'error'
669
+ console.log(`Session closed: ${reason}`);
670
+ });
671
+ ```
672
+
673
+ ### Use Cases
674
+
675
+ **1. Pair Programming**
676
+ ```javascript
677
+ // Developer A creates session
678
+ await client.spawn({ label: 'pair-session' });
679
+ // Share session ID with Developer B
680
+ // Developer B joins with history
681
+ await client.join({ sessionId, requestHistory: true });
682
+ ```
683
+
684
+ **2. Session Persistence**
685
+ ```javascript
686
+ // Start long-running task
687
+ await client.spawn({ shell: '/bin/bash' });
688
+ client.write('npm run build\n');
689
+ client.disconnect(); // Session survives!
690
+
691
+ // Later, reconnect
692
+ await client.connect();
693
+ const sessions = await client.listSessions();
694
+ await client.join({ sessionId: sessions[0].sessionId, requestHistory: true });
695
+ // See build output that happened while disconnected
696
+ ```
697
+
698
+ **3. Monitoring**
699
+ ```javascript
700
+ // Admin joins session in read-only mode
701
+ await client.join({ sessionId, requestHistory: true });
702
+ // Watch activity without interfering
703
+ ```
704
+
439
705
  ## Security
440
706
 
441
707
  **Always configure security for production:**
@@ -461,6 +727,30 @@ const server = new TerminalServer({
461
727
  See the [examples](./examples) directory for complete working examples:
462
728
 
463
729
  - [**docker-container**](./examples/docker-container) - Connect to Docker containers from the browser
730
+ - [**multiplexing**](./examples/multiplexing) - Session multiplexing with multiple clients sharing terminals
731
+
732
+ ### Running Locally (Development)
733
+
734
+ ```bash
735
+ # Clone the repository
736
+ git clone https://github.com/lsadehaan/x-shell.git
737
+ cd x-shell
738
+
739
+ # Install dependencies (including node-pty)
740
+ npm install
741
+ npm install node-pty --save-dev --legacy-peer-deps
742
+
743
+ # Build the project
744
+ npm run build
745
+
746
+ # Start a test container (optional, for Docker exec testing)
747
+ docker run -d --name test-container alpine sleep infinity
748
+
749
+ # Run the example server
750
+ node examples/docker-container/server.js
751
+
752
+ # Open http://localhost:3000 in your browser
753
+ ```
464
754
 
465
755
  ### Quick Start with Docker Compose
466
756
 
@@ -17,9 +17,19 @@ var TerminalClient = class {
17
17
  this.spawnedHandlers = [];
18
18
  this.serverInfoHandlers = [];
19
19
  this.containerListHandlers = [];
20
- // Promise resolvers for spawn
20
+ // Session multiplexing handlers
21
+ this.sessionListHandlers = [];
22
+ this.joinedHandlers = [];
23
+ this.leftHandlers = [];
24
+ this.clientJoinedHandlers = [];
25
+ this.clientLeftHandlers = [];
26
+ this.sessionClosedHandlers = [];
27
+ // Promise resolvers for spawn/join
21
28
  this.spawnResolve = null;
22
29
  this.spawnReject = null;
30
+ this.joinResolve = null;
31
+ this.joinReject = null;
32
+ this.listSessionsResolve = null;
23
33
  this.config = {
24
34
  url: config.url,
25
35
  reconnect: config.reconnect ?? true,
@@ -153,6 +163,11 @@ var TerminalClient = class {
153
163
  this.spawnResolve = null;
154
164
  this.spawnReject = null;
155
165
  }
166
+ if (this.joinReject) {
167
+ this.joinReject(error);
168
+ this.joinResolve = null;
169
+ this.joinReject = null;
170
+ }
156
171
  break;
157
172
  case "serverInfo":
158
173
  this.serverInfo = message.info;
@@ -161,6 +176,64 @@ var TerminalClient = class {
161
176
  case "containerList":
162
177
  this.containerListHandlers.forEach((handler) => handler(message.containers));
163
178
  break;
179
+ case "sessionList":
180
+ this.sessionListHandlers.forEach(
181
+ (handler) => handler(message.sessions)
182
+ );
183
+ if (this.listSessionsResolve) {
184
+ this.listSessionsResolve(message.sessions);
185
+ this.listSessionsResolve = null;
186
+ }
187
+ break;
188
+ case "joined":
189
+ const joinedSession = message.session;
190
+ const history = message.history;
191
+ this.sessionId = message.sessionId;
192
+ this.sessionInfo = {
193
+ sessionId: joinedSession.sessionId,
194
+ shell: joinedSession.shell,
195
+ cwd: joinedSession.cwd,
196
+ cols: joinedSession.cols,
197
+ rows: joinedSession.rows,
198
+ createdAt: joinedSession.createdAt,
199
+ container: joinedSession.container
200
+ };
201
+ this.joinedHandlers.forEach((handler) => handler(joinedSession, history));
202
+ if (this.joinResolve) {
203
+ this.joinResolve(joinedSession);
204
+ this.joinResolve = null;
205
+ this.joinReject = null;
206
+ }
207
+ break;
208
+ case "left":
209
+ const leftSessionId = message.sessionId;
210
+ if (this.sessionId === leftSessionId) {
211
+ this.sessionId = null;
212
+ this.sessionInfo = null;
213
+ }
214
+ this.leftHandlers.forEach((handler) => handler(leftSessionId));
215
+ break;
216
+ case "clientJoined":
217
+ this.clientJoinedHandlers.forEach(
218
+ (handler) => handler(message.sessionId, message.clientCount)
219
+ );
220
+ break;
221
+ case "clientLeft":
222
+ this.clientLeftHandlers.forEach(
223
+ (handler) => handler(message.sessionId, message.clientCount)
224
+ );
225
+ break;
226
+ case "sessionClosed":
227
+ const closedSessionId = message.sessionId;
228
+ const reason = message.reason;
229
+ if (this.sessionId === closedSessionId) {
230
+ this.sessionId = null;
231
+ this.sessionInfo = null;
232
+ }
233
+ this.sessionClosedHandlers.forEach(
234
+ (handler) => handler(closedSessionId, reason)
235
+ );
236
+ break;
164
237
  }
165
238
  }
166
239
  /**
@@ -173,7 +246,7 @@ var TerminalClient = class {
173
246
  return;
174
247
  }
175
248
  if (this.sessionId) {
176
- reject(new Error("Session already spawned. Call kill() first."));
249
+ reject(new Error("Session already active. Call kill() or leave() first."));
177
250
  return;
178
251
  }
179
252
  this.spawnResolve = resolve;
@@ -228,7 +301,7 @@ var TerminalClient = class {
228
301
  );
229
302
  }
230
303
  /**
231
- * Kill the terminal session
304
+ * Kill the terminal session (close and terminate)
232
305
  */
233
306
  kill() {
234
307
  if (!this.ws || this.state !== "connected") {
@@ -247,6 +320,91 @@ var TerminalClient = class {
247
320
  this.sessionInfo = null;
248
321
  }
249
322
  // ==========================================
323
+ // Session Multiplexing Methods
324
+ // ==========================================
325
+ /**
326
+ * List available sessions
327
+ */
328
+ listSessions(filter) {
329
+ return new Promise((resolve, reject) => {
330
+ if (this.state !== "connected" || !this.ws) {
331
+ reject(new Error("Not connected to server"));
332
+ return;
333
+ }
334
+ this.listSessionsResolve = resolve;
335
+ this.ws.send(
336
+ JSON.stringify({
337
+ type: "listSessions",
338
+ filter
339
+ })
340
+ );
341
+ });
342
+ }
343
+ /**
344
+ * Join an existing session
345
+ */
346
+ join(options) {
347
+ return new Promise((resolve, reject) => {
348
+ if (this.state !== "connected" || !this.ws) {
349
+ reject(new Error("Not connected to server"));
350
+ return;
351
+ }
352
+ if (this.sessionId) {
353
+ reject(new Error("Already in a session. Call leave() first."));
354
+ return;
355
+ }
356
+ this.joinResolve = resolve;
357
+ this.joinReject = reject;
358
+ this.ws.send(
359
+ JSON.stringify({
360
+ type: "join",
361
+ options
362
+ })
363
+ );
364
+ });
365
+ }
366
+ /**
367
+ * Leave the current session without killing it
368
+ */
369
+ leave(sessionId) {
370
+ if (!this.ws || this.state !== "connected") {
371
+ console.error("[x-shell] Cannot leave: not connected");
372
+ return;
373
+ }
374
+ const targetSession = sessionId || this.sessionId;
375
+ if (!targetSession) {
376
+ console.error("[x-shell] Cannot leave: no active session");
377
+ return;
378
+ }
379
+ this.ws.send(
380
+ JSON.stringify({
381
+ type: "leave",
382
+ sessionId: targetSession
383
+ })
384
+ );
385
+ if (targetSession === this.sessionId) {
386
+ this.sessionId = null;
387
+ this.sessionInfo = null;
388
+ }
389
+ }
390
+ /**
391
+ * Request session list and trigger onSessionList handlers
392
+ * (Fire-and-forget version of listSessions)
393
+ */
394
+ requestSessionList(filter) {
395
+ this.listSessions(filter).then((sessions) => {
396
+ this.sessionListHandlers.forEach((handler) => {
397
+ try {
398
+ handler(sessions);
399
+ } catch (e) {
400
+ console.error("[x-shell] Error in sessionList handler:", e);
401
+ }
402
+ });
403
+ }).catch((err) => {
404
+ console.error("[x-shell] Failed to list sessions:", err);
405
+ });
406
+ }
407
+ // ==========================================
250
408
  // Event handlers
251
409
  // ==========================================
252
410
  /**
@@ -300,6 +458,42 @@ var TerminalClient = class {
300
458
  onContainerList(handler) {
301
459
  this.containerListHandlers.push(handler);
302
460
  }
461
+ /**
462
+ * Called when session list is received
463
+ */
464
+ onSessionList(handler) {
465
+ this.sessionListHandlers.push(handler);
466
+ }
467
+ /**
468
+ * Called when successfully joined a session
469
+ */
470
+ onJoined(handler) {
471
+ this.joinedHandlers.push(handler);
472
+ }
473
+ /**
474
+ * Called when left a session
475
+ */
476
+ onLeft(handler) {
477
+ this.leftHandlers.push(handler);
478
+ }
479
+ /**
480
+ * Called when another client joins the current session
481
+ */
482
+ onClientJoined(handler) {
483
+ this.clientJoinedHandlers.push(handler);
484
+ }
485
+ /**
486
+ * Called when another client leaves the current session
487
+ */
488
+ onClientLeft(handler) {
489
+ this.clientLeftHandlers.push(handler);
490
+ }
491
+ /**
492
+ * Called when the session is closed by owner or orphan timeout
493
+ */
494
+ onSessionClosed(handler) {
495
+ this.sessionClosedHandlers.push(handler);
496
+ }
303
497
  /**
304
498
  * Request list of available containers
305
499
  */