@dmsdc-ai/aigentry-telepty 0.1.66 → 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 +111 -21
- package/cross-machine.js +221 -0
- package/daemon.js +99 -32
- 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) => {
|
|
@@ -691,12 +678,27 @@ async function main() {
|
|
|
691
678
|
|
|
692
679
|
await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
|
|
693
680
|
|
|
681
|
+
// Detect terminal backend for session registration
|
|
682
|
+
function findKittySocketCli() {
|
|
683
|
+
try {
|
|
684
|
+
const files = require('fs').readdirSync('/tmp').filter(f => f.startsWith('kitty-sock'));
|
|
685
|
+
return files.length > 0;
|
|
686
|
+
} catch { return false; }
|
|
687
|
+
}
|
|
688
|
+
const detectedBackend = process.env.CMUX_WORKSPACE_ID ? 'cmux' : (findKittySocketCli() ? 'kitty' : 'pty');
|
|
689
|
+
|
|
694
690
|
// Register session with daemon
|
|
695
691
|
try {
|
|
696
692
|
const res = await fetchWithAuth(`${DAEMON_URL}/api/sessions/register`, {
|
|
697
693
|
method: 'POST',
|
|
698
694
|
headers: { 'Content-Type': 'application/json' },
|
|
699
|
-
body: JSON.stringify({
|
|
695
|
+
body: JSON.stringify({
|
|
696
|
+
session_id: sessionId,
|
|
697
|
+
command,
|
|
698
|
+
cwd: process.cwd(),
|
|
699
|
+
backend: detectedBackend,
|
|
700
|
+
cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
|
|
701
|
+
})
|
|
700
702
|
});
|
|
701
703
|
const data = await res.json();
|
|
702
704
|
if (!res.ok) {
|
|
@@ -769,7 +771,13 @@ async function main() {
|
|
|
769
771
|
await fetchWithAuth(`${DAEMON_URL}/api/sessions/register`, {
|
|
770
772
|
method: 'POST',
|
|
771
773
|
headers: { 'Content-Type': 'application/json' },
|
|
772
|
-
body: JSON.stringify({
|
|
774
|
+
body: JSON.stringify({
|
|
775
|
+
session_id: sessionId,
|
|
776
|
+
command,
|
|
777
|
+
cwd: process.cwd(),
|
|
778
|
+
backend: detectedBackend,
|
|
779
|
+
cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
|
|
780
|
+
})
|
|
773
781
|
});
|
|
774
782
|
} catch (e) {
|
|
775
783
|
// Registration may fail if session already exists or daemon not ready
|
|
@@ -1853,6 +1861,85 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
1853
1861
|
return;
|
|
1854
1862
|
}
|
|
1855
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
|
+
|
|
1856
1943
|
if (cmd === 'listen' || cmd === 'monitor') {
|
|
1857
1944
|
await ensureDaemonRunning();
|
|
1858
1945
|
|
|
@@ -1938,6 +2025,9 @@ Usage:
|
|
|
1938
2025
|
telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
|
|
1939
2026
|
telepty broadcast "<prompt>" Inject text into ALL active sessions
|
|
1940
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
|
|
1941
2031
|
telepty listen Listen to the event bus and print JSON to stdout
|
|
1942
2032
|
telepty monitor Human-readable real-time billboard of bus events
|
|
1943
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
|
@@ -19,7 +19,7 @@ function persistSessions() {
|
|
|
19
19
|
try {
|
|
20
20
|
const data = {};
|
|
21
21
|
for (const [id, s] of Object.entries(sessions)) {
|
|
22
|
-
data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
|
|
22
|
+
data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, backend: s.backend || null, cmuxWorkspaceId: s.cmuxWorkspaceId || null, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
|
|
23
23
|
}
|
|
24
24
|
fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
|
|
25
25
|
fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
|
|
@@ -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;
|
|
@@ -306,7 +304,7 @@ app.post('/api/sessions/spawn', (req, res) => {
|
|
|
306
304
|
});
|
|
307
305
|
|
|
308
306
|
app.post('/api/sessions/register', (req, res) => {
|
|
309
|
-
const { session_id, command, cwd = process.cwd() } = req.body;
|
|
307
|
+
const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id } = req.body;
|
|
310
308
|
if (!session_id) return res.status(400).json({ error: 'session_id is required' });
|
|
311
309
|
// Entitlement: check session limit for new registrations
|
|
312
310
|
if (!sessions[session_id]) {
|
|
@@ -322,6 +320,8 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
322
320
|
const existing = sessions[session_id];
|
|
323
321
|
if (command) existing.command = command;
|
|
324
322
|
if (cwd) existing.cwd = cwd;
|
|
323
|
+
if (backend) existing.backend = backend;
|
|
324
|
+
if (cmux_workspace_id) existing.cmuxWorkspaceId = cmux_workspace_id;
|
|
325
325
|
console.log(`[REGISTER] Re-registered session ${session_id} (updated metadata)`);
|
|
326
326
|
return res.status(200).json({ session_id, type: 'wrapped', command: existing.command, cwd: existing.cwd, reregistered: true });
|
|
327
327
|
}
|
|
@@ -333,6 +333,8 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
333
333
|
ownerWs: null,
|
|
334
334
|
command: command || 'wrapped',
|
|
335
335
|
cwd,
|
|
336
|
+
backend: backend || 'kitty',
|
|
337
|
+
cmuxWorkspaceId: cmux_workspace_id || null,
|
|
336
338
|
createdAt: new Date().toISOString(),
|
|
337
339
|
lastActivityAt: new Date().toISOString(),
|
|
338
340
|
clients: new Set(),
|
|
@@ -389,6 +391,8 @@ app.get('/api/sessions', (req, res) => {
|
|
|
389
391
|
type: session.type || 'spawned',
|
|
390
392
|
command: session.command,
|
|
391
393
|
cwd: session.cwd,
|
|
394
|
+
backend: session.backend || 'kitty',
|
|
395
|
+
cmuxWorkspaceId: session.cmuxWorkspaceId || null,
|
|
392
396
|
createdAt: session.createdAt,
|
|
393
397
|
lastActivityAt: session.lastActivityAt || null,
|
|
394
398
|
idleSeconds,
|
|
@@ -432,10 +436,23 @@ app.get('/api/meta', (req, res) => {
|
|
|
432
436
|
host: HOST,
|
|
433
437
|
port: Number(PORT),
|
|
434
438
|
machine_id: MACHINE_ID,
|
|
435
|
-
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']
|
|
436
440
|
});
|
|
437
441
|
});
|
|
438
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
|
+
|
|
439
456
|
app.post('/api/sessions/multicast/inject', (req, res) => {
|
|
440
457
|
const { session_ids, prompt } = req.body;
|
|
441
458
|
if (!prompt) return res.status(400).json({ error: 'prompt is required' });
|
|
@@ -452,11 +469,13 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
|
|
|
452
469
|
if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
453
470
|
session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
454
471
|
setTimeout(() => {
|
|
455
|
-
if (session.
|
|
472
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
473
|
+
submitViaCmux(id);
|
|
474
|
+
} else if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
456
475
|
session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
|
|
457
476
|
}
|
|
458
477
|
}, 300);
|
|
459
|
-
results.successful.push({ id, strategy: 'split_cr' });
|
|
478
|
+
results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
|
|
460
479
|
} else {
|
|
461
480
|
results.failed.push({ id, error: 'Wrap process not connected' });
|
|
462
481
|
}
|
|
@@ -502,11 +521,13 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
|
|
|
502
521
|
if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
503
522
|
session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
504
523
|
setTimeout(() => {
|
|
505
|
-
if (session.
|
|
524
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
525
|
+
submitViaCmux(id);
|
|
526
|
+
} else if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
506
527
|
session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
|
|
507
528
|
}
|
|
508
529
|
}, 300);
|
|
509
|
-
results.successful.push({ id, strategy: 'split_cr' });
|
|
530
|
+
results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
|
|
510
531
|
} else {
|
|
511
532
|
results.failed.push({ id, error: 'Wrap process not connected' });
|
|
512
533
|
}
|
|
@@ -680,6 +701,22 @@ function submitViaOsascript(sessionId, keyCombo) {
|
|
|
680
701
|
}
|
|
681
702
|
}
|
|
682
703
|
|
|
704
|
+
function submitViaCmux(sessionId) {
|
|
705
|
+
const { execSync } = require('child_process');
|
|
706
|
+
const session = sessions[sessionId];
|
|
707
|
+
if (!session || !session.cmuxWorkspaceId) return false;
|
|
708
|
+
try {
|
|
709
|
+
execSync(`cmux send-key --workspace ${session.cmuxWorkspaceId} return`, {
|
|
710
|
+
timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
711
|
+
});
|
|
712
|
+
console.log(`[SUBMIT] cmux send-key return for ${sessionId} (workspace ${session.cmuxWorkspaceId})`);
|
|
713
|
+
return true;
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error(`[SUBMIT] cmux send-key failed for ${sessionId}:`, err.message);
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
683
720
|
// POST /api/sessions/:id/submit — CLI-aware submit
|
|
684
721
|
app.post('/api/sessions/:id/submit', (req, res) => {
|
|
685
722
|
const requestedId = req.params.id;
|
|
@@ -692,12 +729,18 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
|
|
692
729
|
console.log(`[SUBMIT] Session ${id} (${session.command}) using strategy: ${strategy}`);
|
|
693
730
|
|
|
694
731
|
let success = false;
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
732
|
+
// cmux backend takes priority
|
|
733
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
734
|
+
success = submitViaCmux(id);
|
|
735
|
+
}
|
|
736
|
+
if (!success) {
|
|
737
|
+
if (strategy === 'pty_cr') {
|
|
738
|
+
success = submitViaPty(session);
|
|
739
|
+
} else if (strategy === 'osascript_cmd_enter') {
|
|
740
|
+
success = submitViaOsascript(id, 'cmd_enter');
|
|
741
|
+
} else {
|
|
742
|
+
success = submitViaPty(session); // fallback
|
|
743
|
+
}
|
|
701
744
|
}
|
|
702
745
|
|
|
703
746
|
if (success) {
|
|
@@ -725,10 +768,16 @@ app.post('/api/sessions/submit-all', (req, res) => {
|
|
|
725
768
|
const strategy = getSubmitStrategy(session.command);
|
|
726
769
|
let success = false;
|
|
727
770
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
771
|
+
// cmux backend takes priority
|
|
772
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
773
|
+
success = submitViaCmux(id);
|
|
774
|
+
}
|
|
775
|
+
if (!success) {
|
|
776
|
+
if (strategy === 'pty_cr') {
|
|
777
|
+
success = submitViaPty(session);
|
|
778
|
+
} else if (strategy === 'osascript_cmd_enter') {
|
|
779
|
+
success = submitViaOsascript(id, 'cmd_enter');
|
|
780
|
+
}
|
|
732
781
|
}
|
|
733
782
|
|
|
734
783
|
if (success) {
|
|
@@ -818,14 +867,24 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
818
867
|
|
|
819
868
|
if (!no_enter) {
|
|
820
869
|
setTimeout(() => {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
870
|
+
let submitted = false;
|
|
871
|
+
|
|
872
|
+
// 1. cmux backend: use cmux send-key
|
|
873
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
874
|
+
submitted = submitViaCmux(id);
|
|
875
|
+
if (submitted) console.log(`[INJECT] cmux submit for ${id}`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// 2. kitty/default backend: osascript primary
|
|
879
|
+
if (!submitted) {
|
|
880
|
+
const submitStrategy = getSubmitStrategy(session.command);
|
|
881
|
+
const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
|
|
882
|
+
submitted = submitViaOsascript(id, keyCombo);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// 3. Fallback: kitty send-text → WS
|
|
886
|
+
if (!submitted) {
|
|
887
|
+
console.log(`[INJECT] submit fallback for ${id}`);
|
|
829
888
|
if (wid && sock) {
|
|
830
889
|
try {
|
|
831
890
|
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
@@ -839,7 +898,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
839
898
|
}
|
|
840
899
|
}
|
|
841
900
|
|
|
842
|
-
// Update tab title
|
|
901
|
+
// Update tab title (kitty-specific, safe to fail)
|
|
843
902
|
if (wid && sock) {
|
|
844
903
|
try {
|
|
845
904
|
require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${id}'`, {
|
|
@@ -848,7 +907,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
848
907
|
} catch {}
|
|
849
908
|
}
|
|
850
909
|
}, 500);
|
|
851
|
-
submitResult = { deferred: true, strategy: 'osascript_with_fallback' };
|
|
910
|
+
submitResult = { deferred: true, strategy: session.backend === 'cmux' ? 'cmux_with_fallback' : 'osascript_with_fallback' };
|
|
852
911
|
}
|
|
853
912
|
} else {
|
|
854
913
|
// Spawned sessions: direct PTY write
|
|
@@ -1023,6 +1082,14 @@ function busAutoRoute(msg) {
|
|
|
1023
1082
|
delivered = true;
|
|
1024
1083
|
} catch {}
|
|
1025
1084
|
}
|
|
1085
|
+
// cmux backend: use WS for text, cmux send-key for enter
|
|
1086
|
+
if (!delivered && targetSession.backend === 'cmux' && targetSession.cmuxWorkspaceId) {
|
|
1087
|
+
if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
|
|
1088
|
+
targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
1089
|
+
setTimeout(() => submitViaCmux(targetId), 500);
|
|
1090
|
+
delivered = true;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1026
1093
|
if (!delivered) {
|
|
1027
1094
|
if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
|
|
1028
1095
|
targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|