@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 +88 -19
- package/cross-machine.js +221 -0
- package/daemon.js +17 -6
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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
|
package/cross-machine.js
ADDED
|
@@ -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
|
|
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
|
|
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' });
|