@dmsdc-ai/aigentry-telepty 0.1.67 → 0.1.68

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/cli.js CHANGED
@@ -15,6 +15,7 @@ const { attachInteractiveTerminal, getTerminalSize } = require('./interactive-te
15
15
  const { getRuntimeInfo } = require('./runtime-info');
16
16
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
17
17
  const { runInteractiveSkillInstaller } = require('./skill-installer');
18
+ const crossMachine = require('./cross-machine');
18
19
  const args = process.argv.slice(2);
19
20
  let pendingTerminalInputError = null;
20
21
  let simulatedPromptErrorInjected = false;
@@ -208,30 +209,16 @@ function getDiscoveryHosts() {
208
209
  .filter(Boolean);
209
210
  extraHosts.forEach((host) => hosts.add(host));
210
211
 
211
- // Also include relay peers for cross-machine session discovery
212
+ // Include relay peers for cross-machine session discovery
212
213
  const relayPeers = String(process.env.TELEPTY_RELAY_PEERS || '')
213
214
  .split(',')
214
215
  .map((host) => host.trim())
215
216
  .filter(Boolean);
216
217
  relayPeers.forEach((host) => hosts.add(host));
217
218
 
218
- if (REMOTE_HOST && REMOTE_HOST !== '127.0.0.1') {
219
- return Array.from(hosts);
220
- }
221
-
222
- try {
223
- const tsStatus = execSync('tailscale status --json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
224
- const tsData = JSON.parse(tsStatus);
225
- if (tsData && tsData.Peer) {
226
- for (const peer of Object.values(tsData.Peer)) {
227
- if (peer.Online && peer.TailscaleIPs && peer.TailscaleIPs.length > 0) {
228
- hosts.add(peer.TailscaleIPs[0]);
229
- }
230
- }
231
- }
232
- } catch (e) {
233
- // Tailscale not available or not running, ignore
234
- }
219
+ // Include SSH tunnel-connected peers
220
+ const connectedHosts = crossMachine.getConnectedHosts();
221
+ connectedHosts.forEach((host) => hosts.add(host));
235
222
 
236
223
  return Array.from(hosts);
237
224
  }
@@ -242,7 +229,7 @@ async function discoverSessions(options = {}) {
242
229
  const allSessions = [];
243
230
 
244
231
  if (!options.silent) {
245
- process.stdout.write('\x1b[36m🔍 Discovering active sessions across your Tailnet...\x1b[0m\n');
232
+ process.stdout.write('\x1b[36m🔍 Discovering active sessions across connected machines...\x1b[0m\n');
246
233
  }
247
234
 
248
235
  await Promise.all(hosts.map(async (host) => {
@@ -1874,6 +1861,85 @@ Discuss the following topic from your project's perspective. Engage with other s
1874
1861
  return;
1875
1862
  }
1876
1863
 
1864
+ // telepty connect <target> [--name <name>] [--port <port>]
1865
+ if (cmd === 'connect') {
1866
+ const target = args[1];
1867
+ if (!target) {
1868
+ console.error('❌ Usage: telepty connect <user@host> [--name <name>] [--port <port>]');
1869
+ process.exit(1);
1870
+ }
1871
+ const nameFlag = args.indexOf('--name');
1872
+ const portFlag = args.indexOf('--port');
1873
+ const options = {};
1874
+ if (nameFlag !== -1 && args[nameFlag + 1]) options.name = args[nameFlag + 1];
1875
+ if (portFlag !== -1 && args[portFlag + 1]) options.port = Number(args[portFlag + 1]);
1876
+
1877
+ process.stdout.write(`\x1b[36m🔗 Connecting to ${target}...\x1b[0m\n`);
1878
+ const result = await crossMachine.connect(target, options);
1879
+ if (result.success) {
1880
+ console.log(`\x1b[32m✅ Connected to ${result.name}\x1b[0m`);
1881
+ console.log(` Machine ID: ${result.machineId}`);
1882
+ console.log(` Local port: ${result.localPort}`);
1883
+ console.log(` Version: ${result.version}`);
1884
+ console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
1885
+ } else {
1886
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1887
+ process.exit(1);
1888
+ }
1889
+ return;
1890
+ }
1891
+
1892
+ // telepty disconnect [<name> | --all]
1893
+ if (cmd === 'disconnect') {
1894
+ if (args[1] === '--all') {
1895
+ const result = crossMachine.disconnectAll();
1896
+ console.log(`\x1b[32m✅ Disconnected from ${result.disconnected.length} peer(s)\x1b[0m`);
1897
+ } else if (args[1]) {
1898
+ const result = crossMachine.disconnect(args[1]);
1899
+ if (result.success) {
1900
+ console.log(`\x1b[32m✅ Disconnected from ${result.name}\x1b[0m`);
1901
+ } else {
1902
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1903
+ }
1904
+ } else {
1905
+ console.error('❌ Usage: telepty disconnect <name> | --all');
1906
+ process.exit(1);
1907
+ }
1908
+ return;
1909
+ }
1910
+
1911
+ // telepty peers [--remove <name>]
1912
+ if (cmd === 'peers') {
1913
+ if (args[1] === '--remove' && args[2]) {
1914
+ crossMachine.removePeer(args[2]);
1915
+ console.log(`\x1b[32m✅ Removed peer ${args[2]}\x1b[0m`);
1916
+ return;
1917
+ }
1918
+
1919
+ const active = crossMachine.listActivePeers();
1920
+ const known = crossMachine.listKnownPeers();
1921
+
1922
+ console.log('\x1b[1mConnected Peers:\x1b[0m');
1923
+ if (active.length === 0) {
1924
+ console.log(' (none)');
1925
+ } else {
1926
+ for (const peer of active) {
1927
+ console.log(` \x1b[32m●\x1b[0m ${peer.name} (${peer.target}) → localhost:${peer.localPort} [${peer.machineId}]`);
1928
+ }
1929
+ }
1930
+
1931
+ const knownNames = Object.keys(known);
1932
+ const disconnected = knownNames.filter(n => !active.find(a => a.name === n));
1933
+ if (disconnected.length > 0) {
1934
+ console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
1935
+ for (const name of disconnected) {
1936
+ const p = known[name];
1937
+ console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
1938
+ }
1939
+ }
1940
+ return;
1941
+ }
1942
+
1877
1943
  if (cmd === 'listen' || cmd === 'monitor') {
1878
1944
  await ensureDaemonRunning();
1879
1945
 
@@ -1959,6 +2025,9 @@ Usage:
1959
2025
  telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
1960
2026
  telepty broadcast "<prompt>" Inject text into ALL active sessions
1961
2027
  telepty rename <old_id[@host]> <new_id> Rename a session (updates terminal title too)
2028
+ telepty connect <user@host> [--name N] [--port P] Connect to a remote machine via SSH tunnel
2029
+ telepty disconnect <name> | --all Disconnect from a remote machine
2030
+ telepty peers [--remove <name>] List connected and known peers
1962
2031
  telepty listen Listen to the event bus and print JSON to stdout
1963
2032
  telepty monitor Human-readable real-time billboard of bus events
1964
2033
  telepty update Update telepty to the latest version
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ const PEERS_PATH = path.join(os.homedir(), '.telepty', 'peers.json');
9
+ const BASE_LOCAL_PORT = 3849; // tunnels start at this port
10
+
11
+ // In-memory active tunnels
12
+ const activeTunnels = new Map(); // name -> { process, localPort, target, connectedAt, ... }
13
+
14
+ function loadPeers() {
15
+ try {
16
+ if (!fs.existsSync(PEERS_PATH)) return { peers: {} };
17
+ return JSON.parse(fs.readFileSync(PEERS_PATH, 'utf8'));
18
+ } catch { return { peers: {} }; }
19
+ }
20
+
21
+ function savePeers(data) {
22
+ try {
23
+ fs.mkdirSync(path.dirname(PEERS_PATH), { recursive: true });
24
+ fs.writeFileSync(PEERS_PATH, JSON.stringify(data, null, 2));
25
+ } catch {}
26
+ }
27
+
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
+ }
34
+
35
+ /**
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 }
40
+ */
41
+ async function connect(target, options = {}) {
42
+ const remotePort = options.port || 3848;
43
+ const localPort = getNextLocalPort();
44
+
45
+ // Parse target
46
+ let sshTarget = target;
47
+ if (!target.includes('@')) {
48
+ sshTarget = `${os.userInfo().username}@${target}`;
49
+ }
50
+
51
+ const name = options.name || target.split('@').pop().split('.')[0]; // short hostname
52
+
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}` };
57
+ }
58
+
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
+ });
98
+
99
+ if (!result.success) {
100
+ tunnel.kill();
101
+ return result;
102
+ }
103
+
104
+ // Verify remote daemon is accessible through tunnel
105
+ 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
+ });
139
+
140
+ return {
141
+ success: true,
142
+ name,
143
+ localPort,
144
+ machineId: peerInfo.machineId,
145
+ version: peerInfo.version
146
+ };
147
+ } catch (err) {
148
+ tunnel.kill();
149
+ return { success: false, error: `Remote daemon not accessible: ${err.message}` };
150
+ }
151
+ }
152
+
153
+ function disconnect(name) {
154
+ const tunnel = activeTunnels.get(name);
155
+ if (!tunnel) {
156
+ return { success: false, error: `Not connected to ${name}` };
157
+ }
158
+ tunnel.process.kill();
159
+ activeTunnels.delete(name);
160
+ return { success: true, name };
161
+ }
162
+
163
+ function disconnectAll() {
164
+ const names = [...activeTunnels.keys()];
165
+ names.forEach(name => disconnect(name));
166
+ return { disconnected: names };
167
+ }
168
+
169
+ function listActivePeers() {
170
+ return [...activeTunnels.entries()].map(([name, info]) => ({
171
+ name,
172
+ target: info.target,
173
+ localPort: info.localPort,
174
+ machineId: info.machineId,
175
+ connectedAt: info.connectedAt,
176
+ host: `127.0.0.1:${info.localPort}`
177
+ }));
178
+ }
179
+
180
+ function listKnownPeers() {
181
+ return loadPeers().peers;
182
+ }
183
+
184
+ /**
185
+ * Get all connected peer hosts for discovery.
186
+ * @returns {string[]} Array of "127.0.0.1:PORT" strings
187
+ */
188
+ function getConnectedHosts() {
189
+ return [...activeTunnels.values()].map(t => `127.0.0.1:${t.localPort}`);
190
+ }
191
+
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
+ function getPeerHost(name) {
198
+ const tunnel = activeTunnels.get(name);
199
+ return tunnel ? `127.0.0.1:${tunnel.localPort}` : null;
200
+ }
201
+
202
+ function removePeer(name) {
203
+ disconnect(name); // disconnect if active
204
+ const peers = loadPeers();
205
+ delete peers.peers[name];
206
+ savePeers(peers);
207
+ return { success: true };
208
+ }
209
+
210
+ module.exports = {
211
+ connect,
212
+ disconnect,
213
+ disconnectAll,
214
+ listActivePeers,
215
+ listKnownPeers,
216
+ getConnectedHosts,
217
+ getPeerHost,
218
+ removePeer,
219
+ loadPeers,
220
+ PEERS_PATH
221
+ };
package/daemon.js CHANGED
@@ -91,14 +91,12 @@ function verifyJwt(token) {
91
91
  function isAllowedPeer(ip) {
92
92
  if (!ip) return false;
93
93
  const cleanIp = ip.replace('::ffff:', '');
94
- // Localhost always allowed
94
+ // Localhost always allowed (includes SSH tunnel traffic)
95
95
  if (cleanIp === '127.0.0.1' || ip === '::1') return true;
96
- // Tailscale range (100.x.y.z)
97
- if (cleanIp.startsWith('100.')) return true;
98
96
  // Peer allowlist
99
97
  if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
100
98
  // No allowlist = allow all authenticated
101
- return false;
99
+ return true;
102
100
  }
103
101
 
104
102
  // Authentication Middleware
@@ -106,7 +104,7 @@ app.use((req, res, next) => {
106
104
  const clientIp = req.ip;
107
105
 
108
106
  if (isAllowedPeer(clientIp)) {
109
- return next(); // Trust local, Tailscale, and allowlisted peers
107
+ return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
110
108
  }
111
109
 
112
110
  const token = req.headers['x-telepty-token'] || req.query.token;
@@ -438,10 +436,23 @@ app.get('/api/meta', (req, res) => {
438
436
  host: HOST,
439
437
  port: Number(PORT),
440
438
  machine_id: MACHINE_ID,
441
- capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads']
439
+ capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine']
442
440
  });
443
441
  });
444
442
 
443
+ // Peer management endpoint (for cross-machine module)
444
+ app.get('/api/peers', (req, res) => {
445
+ try {
446
+ const crossMachine = require('./cross-machine');
447
+ res.json({
448
+ active: crossMachine.listActivePeers(),
449
+ known: crossMachine.listKnownPeers()
450
+ });
451
+ } catch {
452
+ res.json({ active: [], known: {} });
453
+ }
454
+ });
455
+
445
456
  app.post('/api/sessions/multicast/inject', (req, res) => {
446
457
  const { session_ids, prompt } = req.body;
447
458
  if (!prompt) return res.status(400).json({ error: 'prompt is required' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",