@dmsdc-ai/aigentry-telepty 0.1.68 → 0.1.70

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/cross-machine.js CHANGED
@@ -1,15 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const { spawn } = require('child_process');
3
+ const { execSync, spawn } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
 
8
8
  const PEERS_PATH = path.join(os.homedir(), '.telepty', 'peers.json');
9
- const BASE_LOCAL_PORT = 3849; // tunnels start at this port
9
+ const CONTROL_DIR = path.join(os.homedir(), '.telepty', 'ssh');
10
10
 
11
- // In-memory active tunnels
12
- const activeTunnels = new Map(); // name -> { process, localPort, target, connectedAt, ... }
11
+ // SSH ControlMaster socket path pattern
12
+ function controlPath(target) {
13
+ return path.join(CONTROL_DIR, `ctrl-${target.replace(/[^a-zA-Z0-9@.-]/g, '_')}`);
14
+ }
13
15
 
14
16
  function loadPeers() {
15
17
  try {
@@ -25,155 +27,188 @@ function savePeers(data) {
25
27
  } catch {}
26
28
  }
27
29
 
28
- function getNextLocalPort() {
29
- const usedPorts = new Set([...activeTunnels.values()].map(t => t.localPort));
30
- let port = BASE_LOCAL_PORT;
31
- while (usedPorts.has(port)) port++;
32
- return port;
33
- }
30
+ // In-memory active peers
31
+ const activePeers = new Map(); // name -> { target, controlSocket, connectedAt, machineId }
34
32
 
35
33
  /**
36
- * Connect to a remote machine via SSH tunnel.
37
- * @param {string} target - "user@host" or "host" (uses current user)
38
- * @param {object} options - { name, port }
39
- * @returns {Promise<object>} - { success, name, localPort, machineId, version } or { success: false, error }
34
+ * Connect to a remote machine via SSH ControlMaster.
40
35
  */
41
36
  async function connect(target, options = {}) {
42
- const remotePort = options.port || 3848;
43
- const localPort = getNextLocalPort();
44
-
45
- // Parse target
46
37
  let sshTarget = target;
47
38
  if (!target.includes('@')) {
48
39
  sshTarget = `${os.userInfo().username}@${target}`;
49
40
  }
50
41
 
51
- const name = options.name || target.split('@').pop().split('.')[0]; // short hostname
42
+ const name = options.name || target.split('@').pop().split('.')[0];
52
43
 
53
- // Check if already connected
54
- if (activeTunnels.has(name)) {
55
- const existing = activeTunnels.get(name);
56
- return { success: false, error: `Already connected to ${name} on port ${existing.localPort}` };
44
+ if (activePeers.has(name)) {
45
+ return { success: false, error: `Already connected to ${name}` };
57
46
  }
58
47
 
59
- // Create SSH tunnel
60
- const tunnel = spawn('ssh', [
61
- '-N', // No remote command
62
- '-L', `${localPort}:localhost:${remotePort}`, // Local port forwarding
63
- '-o', 'ServerAliveInterval=30', // Keep alive
64
- '-o', 'ServerAliveCountMax=3', // Disconnect after 3 missed keepalives
65
- '-o', 'ExitOnForwardFailure=yes', // Fail if port forwarding fails
66
- '-o', 'ConnectTimeout=10', // Connection timeout
67
- '-o', 'StrictHostKeyChecking=accept-new', // Auto-accept new host keys
68
- sshTarget
69
- ], {
70
- stdio: ['ignore', 'pipe', 'pipe'],
71
- detached: false
72
- });
73
-
74
- // Wait for tunnel to establish or fail
75
- const result = await new Promise((resolve) => {
76
- let stderr = '';
77
- const timeout = setTimeout(() => {
78
- // If process is still running after 5s, tunnel is up
79
- if (!tunnel.killed && tunnel.exitCode === null) {
80
- resolve({ success: true });
81
- } else {
82
- resolve({ success: false, error: stderr || 'Connection timeout' });
83
- }
84
- }, 5000);
85
-
86
- tunnel.stderr.on('data', (data) => { stderr += data.toString(); });
87
- tunnel.on('exit', (code) => {
88
- clearTimeout(timeout);
89
- if (code !== 0) {
90
- resolve({ success: false, error: stderr || `SSH exited with code ${code}` });
91
- }
92
- });
93
- tunnel.on('error', (err) => {
94
- clearTimeout(timeout);
95
- resolve({ success: false, error: err.message });
96
- });
97
- });
48
+ // Ensure control directory exists
49
+ fs.mkdirSync(CONTROL_DIR, { recursive: true });
50
+
51
+ const ctrlPath = controlPath(sshTarget);
98
52
 
99
- if (!result.success) {
100
- tunnel.kill();
101
- return result;
53
+ // Start SSH ControlMaster
54
+ try {
55
+ execSync([
56
+ 'ssh', '-o', 'ControlMaster=auto',
57
+ '-o', `ControlPath=${ctrlPath}`,
58
+ '-o', 'ControlPersist=600',
59
+ '-o', 'ConnectTimeout=10',
60
+ '-o', 'ServerAliveInterval=30',
61
+ '-o', 'ServerAliveCountMax=3',
62
+ '-o', 'StrictHostKeyChecking=accept-new',
63
+ '-N', '-f', // Go to background
64
+ sshTarget
65
+ ].join(' '), { timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
66
+ } catch (err) {
67
+ return { success: false, error: `SSH connection failed: ${err.message}` };
102
68
  }
103
69
 
104
- // Verify remote daemon is accessible through tunnel
70
+ // Verify remote telepty is available
71
+ let machineId = name;
105
72
  try {
106
- const res = await fetch(`http://127.0.0.1:${localPort}/api/meta`, {
107
- signal: AbortSignal.timeout(3000)
108
- });
109
- if (!res.ok) throw new Error('Daemon not responding');
110
- const meta = await res.json();
111
-
112
- const peerInfo = {
113
- process: tunnel,
114
- localPort,
115
- target: sshTarget,
116
- name,
117
- machineId: meta.machine_id || name,
118
- version: meta.version || 'unknown',
119
- connectedAt: new Date().toISOString()
120
- };
121
-
122
- activeTunnels.set(name, peerInfo);
123
-
124
- // Persist peer for reconnection
125
- const peers = loadPeers();
126
- peers.peers[name] = {
127
- target: sshTarget,
128
- remotePort,
129
- lastConnected: peerInfo.connectedAt,
130
- machineId: peerInfo.machineId
131
- };
132
- savePeers(peers);
133
-
134
- // Monitor tunnel health
135
- tunnel.on('exit', () => {
136
- console.log(`[PEER] SSH tunnel to ${name} disconnected`);
137
- activeTunnels.delete(name);
138
- });
73
+ const output = execSync(
74
+ `ssh -o ControlPath=${ctrlPath} ${sshTarget} "hostname"`,
75
+ { timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
76
+ ).trim();
77
+ if (output) machineId = output;
78
+ } catch {}
139
79
 
140
- return {
141
- success: true,
142
- name,
143
- localPort,
144
- machineId: peerInfo.machineId,
145
- version: peerInfo.version
146
- };
80
+ // Verify telepty CLI is available on remote
81
+ try {
82
+ execSync(
83
+ `ssh -o ControlPath=${ctrlPath} ${sshTarget} "telepty list --json"`,
84
+ { timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
85
+ );
147
86
  } catch (err) {
148
- tunnel.kill();
149
- return { success: false, error: `Remote daemon not accessible: ${err.message}` };
87
+ // Clean up ControlMaster
88
+ try { execSync(`ssh -O exit -o ControlPath=${ctrlPath} ${sshTarget}`, { stdio: 'pipe' }); } catch {}
89
+ return { success: false, error: `Remote telepty not available: ${err.message}` };
150
90
  }
91
+
92
+ const peerInfo = {
93
+ target: sshTarget,
94
+ controlSocket: ctrlPath,
95
+ name,
96
+ machineId,
97
+ connectedAt: new Date().toISOString()
98
+ };
99
+
100
+ activePeers.set(name, peerInfo);
101
+
102
+ // Persist peer
103
+ const peers = loadPeers();
104
+ peers.peers[name] = {
105
+ target: sshTarget,
106
+ lastConnected: peerInfo.connectedAt,
107
+ machineId
108
+ };
109
+ savePeers(peers);
110
+
111
+ return { success: true, name, machineId };
151
112
  }
152
113
 
153
114
  function disconnect(name) {
154
- const tunnel = activeTunnels.get(name);
155
- if (!tunnel) {
115
+ const peer = activePeers.get(name);
116
+ if (!peer) {
156
117
  return { success: false, error: `Not connected to ${name}` };
157
118
  }
158
- tunnel.process.kill();
159
- activeTunnels.delete(name);
119
+
120
+ // Close ControlMaster
121
+ try {
122
+ execSync(`ssh -O exit -o ControlPath=${peer.controlSocket} ${peer.target}`, {
123
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
124
+ });
125
+ } catch {}
126
+
127
+ activePeers.delete(name);
160
128
  return { success: true, name };
161
129
  }
162
130
 
163
131
  function disconnectAll() {
164
- const names = [...activeTunnels.keys()];
132
+ const names = [...activePeers.keys()];
165
133
  names.forEach(name => disconnect(name));
166
134
  return { disconnected: names };
167
135
  }
168
136
 
137
+ /**
138
+ * List sessions on a remote peer via SSH.
139
+ * @returns {Array} sessions with host info
140
+ */
141
+ function listRemoteSessions(name) {
142
+ const peer = activePeers.get(name);
143
+ if (!peer) return [];
144
+
145
+ try {
146
+ const output = execSync(
147
+ `ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty list --json"`,
148
+ { timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
149
+ );
150
+ const sessions = JSON.parse(output);
151
+ return sessions.map(s => ({ ...s, host: peer.target, peerName: name, remote: true }));
152
+ } catch {
153
+ return [];
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Discover sessions across all connected peers.
159
+ * @returns {Array} all remote sessions
160
+ */
161
+ function discoverAllRemoteSessions() {
162
+ const allSessions = [];
163
+ for (const [name] of activePeers) {
164
+ allSessions.push(...listRemoteSessions(name));
165
+ }
166
+ return allSessions;
167
+ }
168
+
169
+ /**
170
+ * Inject text into a remote session via SSH.
171
+ */
172
+ function remoteInject(name, sessionId, prompt, options = {}) {
173
+ const peer = activePeers.get(name);
174
+ if (!peer) return { success: false, error: `Not connected to ${name}` };
175
+
176
+ try {
177
+ const escaped = prompt.replace(/'/g, "'\\''");
178
+ const fromFlag = options.from ? `--from '${options.from}'` : '';
179
+ const noEnterFlag = options.no_enter ? '--no-enter' : '';
180
+ execSync(
181
+ `ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty inject ${noEnterFlag} ${fromFlag} '${sessionId}' '${escaped}'"`,
182
+ { timeout: 15000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
183
+ );
184
+ return { success: true };
185
+ } catch (err) {
186
+ return { success: false, error: err.message };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Spawn an interactive SSH attach to a remote session.
192
+ * Returns the child process for stdin/stdout piping.
193
+ */
194
+ function remoteAttach(name, sessionId) {
195
+ const peer = activePeers.get(name);
196
+ if (!peer) return null;
197
+
198
+ return spawn('ssh', [
199
+ '-o', `ControlPath=${peer.controlSocket}`,
200
+ '-t', // Force TTY allocation
201
+ peer.target,
202
+ 'telepty', 'attach', sessionId
203
+ ], { stdio: ['pipe', 'pipe', 'pipe'] });
204
+ }
205
+
169
206
  function listActivePeers() {
170
- return [...activeTunnels.entries()].map(([name, info]) => ({
207
+ return [...activePeers.entries()].map(([name, info]) => ({
171
208
  name,
172
209
  target: info.target,
173
- localPort: info.localPort,
174
210
  machineId: info.machineId,
175
- connectedAt: info.connectedAt,
176
- host: `127.0.0.1:${info.localPort}`
211
+ connectedAt: info.connectedAt
177
212
  }));
178
213
  }
179
214
 
@@ -182,25 +217,31 @@ function listKnownPeers() {
182
217
  }
183
218
 
184
219
  /**
185
- * Get all connected peer hosts for discovery.
186
- * @returns {string[]} Array of "127.0.0.1:PORT" strings
220
+ * Find which peer has a given session.
221
+ * @returns {{ peerName, peer } | null}
187
222
  */
223
+ function findSessionPeer(sessionId) {
224
+ for (const [name] of activePeers) {
225
+ const sessions = listRemoteSessions(name);
226
+ if (sessions.some(s => s.id === sessionId)) {
227
+ return { peerName: name, peer: activePeers.get(name) };
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ // Backward compat - getConnectedHosts no longer returns HTTP hosts
234
+ // Instead returns peer names for SSH-based discovery
188
235
  function getConnectedHosts() {
189
- return [...activeTunnels.values()].map(t => `127.0.0.1:${t.localPort}`);
236
+ return []; // No HTTP hosts - use discoverAllRemoteSessions() instead
190
237
  }
191
238
 
192
- /**
193
- * Get connected host for a specific peer.
194
- * @param {string} name - Peer name
195
- * @returns {string|null} "127.0.0.1:PORT" or null
196
- */
197
239
  function getPeerHost(name) {
198
- const tunnel = activeTunnels.get(name);
199
- return tunnel ? `127.0.0.1:${tunnel.localPort}` : null;
240
+ return null; // No HTTP host - use SSH direct
200
241
  }
201
242
 
202
243
  function removePeer(name) {
203
- disconnect(name); // disconnect if active
244
+ disconnect(name);
204
245
  const peers = loadPeers();
205
246
  delete peers.peers[name];
206
247
  savePeers(peers);
@@ -217,5 +258,10 @@ module.exports = {
217
258
  getPeerHost,
218
259
  removePeer,
219
260
  loadPeers,
261
+ listRemoteSessions,
262
+ discoverAllRemoteSessions,
263
+ remoteInject,
264
+ remoteAttach,
265
+ findSessionPeer,
220
266
  PEERS_PATH
221
267
  };