sessioncast-cli 2.0.3 → 2.0.4

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.
@@ -74,8 +74,7 @@ class AgentRunner {
74
74
  // If no config file found but agent token exists, create default config
75
75
  if ((!finalPath || !fs.existsSync(finalPath)) && agentToken) {
76
76
  console.log('Using OAuth authentication');
77
- const hostname = os.hostname();
78
- const machineId = `${hostname}-${Date.now()}`;
77
+ const machineId = os.hostname();
79
78
  return {
80
79
  machineId,
81
80
  relay: (0, config_1.getRelayUrl)(),
@@ -15,6 +15,8 @@ export declare class TmuxSessionHandler {
15
15
  private lastForceSendTime;
16
16
  private lastChangeTime;
17
17
  private captureTimer;
18
+ private lastPaneIds;
19
+ private lastPaneScreens;
18
20
  private pendingUploads;
19
21
  private uploadDir;
20
22
  constructor(options: SessionHandlerOptions);
@@ -54,6 +54,9 @@ class TmuxSessionHandler {
54
54
  this.lastForceSendTime = 0;
55
55
  this.lastChangeTime = 0;
56
56
  this.captureTimer = null;
57
+ // Multi-pane tracking
58
+ this.lastPaneIds = '';
59
+ this.lastPaneScreens = new Map();
57
60
  // File upload handling
58
61
  this.pendingUploads = new Map();
59
62
  this.uploadDir = process.cwd(); // Default to current working directory
@@ -90,8 +93,8 @@ class TmuxSessionHandler {
90
93
  console.log(`[${this.tmuxSession}] Disconnected: code=${code}, reason=${reason}`);
91
94
  this.stopScreenCapture();
92
95
  });
93
- this.wsClient.on('keys', (keys) => {
94
- this.handleKeys(keys);
96
+ this.wsClient.on('keys', (keys, paneId) => {
97
+ this.handleKeys(keys, paneId);
95
98
  });
96
99
  this.wsClient.on('resize', ({ cols, rows }) => {
97
100
  console.log(`[${this.tmuxSession}] Resize: ${cols}x${rows}`);
@@ -281,8 +284,9 @@ class TmuxSessionHandler {
281
284
  };
282
285
  return types[ext] || 'application/octet-stream';
283
286
  }
284
- handleKeys(keys) {
285
- tmux.sendKeys(this.tmuxSession, keys, false);
287
+ handleKeys(keys, paneId) {
288
+ const target = paneId || this.tmuxSession;
289
+ tmux.sendKeys(target, keys, false);
286
290
  }
287
291
  startScreenCapture() {
288
292
  if (this.captureTimer)
@@ -294,35 +298,83 @@ class TmuxSessionHandler {
294
298
  return;
295
299
  }
296
300
  try {
297
- const screen = tmux.capturePane(this.tmuxSession);
298
- if (screen !== null) {
299
- const now = Date.now();
300
- const changed = screen !== this.lastScreen;
301
- const forceTime = (now - this.lastForceSendTime) >= FORCE_SEND_INTERVAL_MS;
302
- if (changed || forceTime) {
303
- this.lastScreen = screen;
304
- this.lastForceSendTime = now;
305
- if (changed) {
306
- this.lastChangeTime = now;
307
- }
308
- // Send clear screen + content
309
- const fullOutput = '\x1b[2J\x1b[H' + screen;
310
- const data = Buffer.from(fullOutput, 'utf-8');
311
- // Compress if enabled and data is large enough
312
- if (USE_COMPRESSION && data.length > MIN_COMPRESS_SIZE) {
313
- this.wsClient.sendScreenCompressed(data);
314
- }
315
- else {
316
- this.wsClient.sendScreen(data);
301
+ const panes = tmux.listPanes(this.tmuxSession);
302
+ const now = Date.now();
303
+ const forceTime = (now - this.lastForceSendTime) >= FORCE_SEND_INTERVAL_MS;
304
+ if (panes && panes.length > 1) {
305
+ // Multi-pane mode
306
+ const currentPaneIds = panes.map(p => p.id).join(',');
307
+ if (currentPaneIds !== this.lastPaneIds || forceTime) {
308
+ // Layout changed or periodic resend for late-joining viewers
309
+ this.lastPaneIds = currentPaneIds;
310
+ this.wsClient.sendPaneLayout(panes);
311
+ }
312
+ // Capture each pane individually
313
+ for (const pane of panes) {
314
+ const screen = tmux.capturePaneById(this.tmuxSession, pane.id);
315
+ if (screen !== null) {
316
+ const lastScreen = this.lastPaneScreens.get(pane.id);
317
+ if (screen !== lastScreen || forceTime) {
318
+ this.lastPaneScreens.set(pane.id, screen);
319
+ if (screen !== lastScreen) {
320
+ this.lastChangeTime = now;
321
+ }
322
+ const fullOutput = '\x1b[2J\x1b[H' + screen;
323
+ const data = Buffer.from(fullOutput, 'utf-8');
324
+ const meta = { pane: pane.id };
325
+ if (USE_COMPRESSION && data.length > MIN_COMPRESS_SIZE) {
326
+ this.wsClient.sendScreenCompressedWithMeta(data, meta);
327
+ }
328
+ else {
329
+ this.wsClient.sendScreenWithMeta(data, meta);
330
+ }
331
+ }
317
332
  }
318
333
  }
319
- // Adaptive sleep: faster when active, slower when idle
334
+ if (forceTime) {
335
+ this.lastForceSendTime = now;
336
+ }
337
+ // Adaptive sleep
320
338
  const isActive = (now - this.lastChangeTime) < ACTIVE_THRESHOLD_MS;
321
339
  const sleepMs = isActive ? CAPTURE_INTERVAL_ACTIVE_MS : CAPTURE_INTERVAL_IDLE_MS;
322
340
  this.captureTimer = setTimeout(capture, sleepMs);
323
341
  }
324
342
  else {
325
- this.captureTimer = setTimeout(capture, CAPTURE_INTERVAL_IDLE_MS);
343
+ // Single pane mode (existing logic)
344
+ if (this.lastPaneIds !== '') {
345
+ // Was multi-pane, now single — notify viewers
346
+ this.lastPaneIds = '';
347
+ this.lastPaneScreens.clear();
348
+ this.wsClient.sendPaneLayout([]);
349
+ }
350
+ const screen = tmux.capturePane(this.tmuxSession);
351
+ if (screen !== null) {
352
+ const changed = screen !== this.lastScreen;
353
+ if (changed || forceTime) {
354
+ this.lastScreen = screen;
355
+ this.lastForceSendTime = now;
356
+ if (changed) {
357
+ this.lastChangeTime = now;
358
+ }
359
+ // Send clear screen + content
360
+ const fullOutput = '\x1b[2J\x1b[H' + screen;
361
+ const data = Buffer.from(fullOutput, 'utf-8');
362
+ // Compress if enabled and data is large enough
363
+ if (USE_COMPRESSION && data.length > MIN_COMPRESS_SIZE) {
364
+ this.wsClient.sendScreenCompressed(data);
365
+ }
366
+ else {
367
+ this.wsClient.sendScreen(data);
368
+ }
369
+ }
370
+ // Adaptive sleep: faster when active, slower when idle
371
+ const isActive = (now - this.lastChangeTime) < ACTIVE_THRESHOLD_MS;
372
+ const sleepMs = isActive ? CAPTURE_INTERVAL_ACTIVE_MS : CAPTURE_INTERVAL_IDLE_MS;
373
+ this.captureTimer = setTimeout(capture, sleepMs);
374
+ }
375
+ else {
376
+ this.captureTimer = setTimeout(capture, CAPTURE_INTERVAL_IDLE_MS);
377
+ }
326
378
  }
327
379
  }
328
380
  catch (error) {
@@ -2,9 +2,21 @@
2
2
  * Interface for tmux operations.
3
3
  * Implementations handle platform-specific differences (Unix vs Windows/itmux).
4
4
  */
5
+ export interface PaneData {
6
+ id: string;
7
+ index: number;
8
+ width: number;
9
+ height: number;
10
+ top: number;
11
+ left: number;
12
+ active: boolean;
13
+ title: string;
14
+ }
5
15
  export interface TmuxExecutor {
6
16
  listSessions(): string[];
7
17
  capturePane(session: string): string | null;
18
+ capturePaneById(session: string, paneId: string): string | null;
19
+ listPanes(session: string): PaneData[] | null;
8
20
  sendKeys(session: string, keys: string): boolean;
9
21
  sendSpecialKey(session: string, key: string): boolean;
10
22
  resizeWindow(session: string, cols: number, rows: number): boolean;
@@ -21,6 +33,8 @@ export interface TmuxExecutor {
21
33
  export declare class UnixTmuxExecutor implements TmuxExecutor {
22
34
  listSessions(): string[];
23
35
  capturePane(session: string): string | null;
36
+ capturePaneById(session: string, paneId: string): string | null;
37
+ listPanes(session: string): PaneData[] | null;
24
38
  sendKeys(session: string, keys: string): boolean;
25
39
  sendSpecialKey(session: string, key: string): boolean;
26
40
  resizeWindow(session: string, cols: number, rows: number): boolean;
@@ -42,6 +56,8 @@ export declare class WindowsTmuxExecutor implements TmuxExecutor {
42
56
  private executeCommand;
43
57
  listSessions(): string[];
44
58
  capturePane(session: string): string | null;
59
+ capturePaneById(session: string, paneId: string): string | null;
60
+ listPanes(session: string): PaneData[] | null;
45
61
  sendKeys(session: string, keys: string): boolean;
46
62
  sendSpecialKey(session: string, key: string): boolean;
47
63
  resizeWindow(session: string, cols: number, rows: number): boolean;
@@ -73,6 +73,35 @@ class UnixTmuxExecutor {
73
73
  return null;
74
74
  }
75
75
  }
76
+ capturePaneById(session, paneId) {
77
+ try {
78
+ const output = (0, child_process_1.execSync)(`tmux capture-pane -t "${paneId}" -p -e -N`, {
79
+ encoding: 'utf-8',
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ maxBuffer: 10 * 1024 * 1024
82
+ });
83
+ return output.replace(/\n/g, '\r\n');
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ listPanes(session) {
90
+ try {
91
+ const output = (0, child_process_1.execSync)(`tmux list-panes -t "${session}" -F "#{pane_id}:#{pane_index}:#{pane_width}:#{pane_height}:#{pane_top}:#{pane_left}:#{?pane_active,1,0}:#{pane_title}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
92
+ return output.trim().split('\n').filter(Boolean).map(line => {
93
+ const [id, index, width, height, top, left, active, ...titleParts] = line.split(':');
94
+ return {
95
+ id, index: parseInt(index), width: parseInt(width), height: parseInt(height),
96
+ top: parseInt(top), left: parseInt(left),
97
+ active: active === '1', title: titleParts.join(':') || ''
98
+ };
99
+ });
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
76
105
  sendKeys(session, keys) {
77
106
  try {
78
107
  const escaped = this.escapeForShell(keys);
@@ -235,6 +264,37 @@ class WindowsTmuxExecutor {
235
264
  return null;
236
265
  }
237
266
  }
267
+ capturePaneById(session, paneId) {
268
+ try {
269
+ const escaped = this.escapeSession(paneId);
270
+ const output = this.executeCommand(`tmux capture-pane -t '${escaped}' -p -e -N`);
271
+ if (!output)
272
+ return null;
273
+ return output.replace(/\n/g, '\r\n');
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ listPanes(session) {
280
+ try {
281
+ const escaped = this.escapeSession(session);
282
+ const output = this.executeCommand(`tmux list-panes -t '${escaped}' -F "#{pane_id}:#{pane_index}:#{pane_width}:#{pane_height}:#{pane_top}:#{pane_left}:#{?pane_active,1,0}:#{pane_title}"`);
283
+ if (!output)
284
+ return null;
285
+ return output.trim().split('\n').filter(Boolean).map(line => {
286
+ const [id, index, width, height, top, left, active, ...titleParts] = line.split(':');
287
+ return {
288
+ id, index: parseInt(index), width: parseInt(width), height: parseInt(height),
289
+ top: parseInt(top), left: parseInt(left),
290
+ active: active === '1', title: titleParts.join(':') || ''
291
+ };
292
+ });
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
238
298
  sendKeys(session, keys) {
239
299
  try {
240
300
  const escapedSession = this.escapeSession(session);
@@ -1,3 +1,4 @@
1
+ import { PaneData } from './tmux-executor';
1
2
  import { TmuxSession } from './types';
2
3
  /**
3
4
  * Scan for all tmux sessions
@@ -43,3 +44,12 @@ export declare function getActivePane(sessionName: string): string | null;
43
44
  * Get the current working directory of a pane
44
45
  */
45
46
  export declare function getPaneCwd(sessionName: string, paneId?: string): string | null;
47
+ /**
48
+ * List all panes in a tmux session
49
+ */
50
+ export declare function listPanes(sessionName: string): PaneData[] | null;
51
+ /**
52
+ * Capture a specific pane by its pane ID (e.g., %0, %1)
53
+ */
54
+ export declare function capturePaneById(sessionName: string, paneId: string): string | null;
55
+ export type { PaneData } from './tmux-executor';
@@ -11,6 +11,8 @@ exports.isAvailable = isAvailable;
11
11
  exports.getVersion = getVersion;
12
12
  exports.getActivePane = getActivePane;
13
13
  exports.getPaneCwd = getPaneCwd;
14
+ exports.listPanes = listPanes;
15
+ exports.capturePaneById = capturePaneById;
14
16
  const tmux_executor_1 = require("./tmux-executor");
15
17
  // Lazy-initialized executor (created on first use)
16
18
  let executor = null;
@@ -145,3 +147,15 @@ function getActivePane(sessionName) {
145
147
  function getPaneCwd(sessionName, paneId) {
146
148
  return getExecutor().getPaneCwd(sessionName, paneId);
147
149
  }
150
+ /**
151
+ * List all panes in a tmux session
152
+ */
153
+ function listPanes(sessionName) {
154
+ return getExecutor().listPanes(sessionName);
155
+ }
156
+ /**
157
+ * Capture a specific pane by its pane ID (e.g., %0, %1)
158
+ */
159
+ function capturePaneById(sessionName, paneId) {
160
+ return getExecutor().capturePaneById(sessionName, paneId);
161
+ }
@@ -30,7 +30,19 @@ export declare class RelayWebSocketClient extends EventEmitter {
30
30
  private scheduleReconnect;
31
31
  send(message: Message): boolean;
32
32
  sendScreen(data: Buffer): boolean;
33
+ sendScreenWithMeta(data: Buffer, meta: Record<string, string>): boolean;
33
34
  sendScreenCompressed(data: Buffer): boolean;
35
+ sendScreenCompressedWithMeta(data: Buffer, meta: Record<string, string>): boolean;
36
+ sendPaneLayout(panes: Array<{
37
+ id: string;
38
+ index: number;
39
+ width: number;
40
+ height: number;
41
+ top: number;
42
+ left: number;
43
+ active: boolean;
44
+ title: string;
45
+ }>): boolean;
34
46
  /**
35
47
  * Send file content to be displayed in the web FileViewer
36
48
  * @param filename - The name of the file
@@ -119,7 +119,7 @@ class RelayWebSocketClient extends events_1.EventEmitter {
119
119
  switch (message.type) {
120
120
  case 'keys':
121
121
  if (message.session === this.sessionId && message.payload) {
122
- this.emit('keys', message.payload);
122
+ this.emit('keys', message.payload, message.meta?.pane);
123
123
  }
124
124
  break;
125
125
  case 'resize':
@@ -249,6 +249,16 @@ class RelayWebSocketClient extends events_1.EventEmitter {
249
249
  payload: base64Data
250
250
  });
251
251
  }
252
+ sendScreenWithMeta(data, meta) {
253
+ if (!this.isConnected)
254
+ return false;
255
+ return this.send({
256
+ type: 'screen',
257
+ session: this.sessionId,
258
+ payload: data.toString('base64'),
259
+ meta
260
+ });
261
+ }
252
262
  sendScreenCompressed(data) {
253
263
  if (!this.isConnected)
254
264
  return false;
@@ -265,6 +275,31 @@ class RelayWebSocketClient extends events_1.EventEmitter {
265
275
  return this.sendScreen(data);
266
276
  }
267
277
  }
278
+ sendScreenCompressedWithMeta(data, meta) {
279
+ if (!this.isConnected)
280
+ return false;
281
+ try {
282
+ const compressed = zlib.gzipSync(data);
283
+ return this.send({
284
+ type: 'screenGz',
285
+ session: this.sessionId,
286
+ payload: compressed.toString('base64'),
287
+ meta
288
+ });
289
+ }
290
+ catch {
291
+ return this.sendScreenWithMeta(data, meta);
292
+ }
293
+ }
294
+ sendPaneLayout(panes) {
295
+ if (!this.isConnected)
296
+ return false;
297
+ return this.send({
298
+ type: 'paneLayout',
299
+ session: this.sessionId,
300
+ payload: JSON.stringify(panes)
301
+ });
302
+ }
268
303
  /**
269
304
  * Send file content to be displayed in the web FileViewer
270
305
  * @param filename - The name of the file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessioncast-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "SessionCast CLI - Control your agents from anywhere",
5
5
  "main": "dist/index.js",
6
6
  "bin": {