@dmsdc-ai/aigentry-telepty 0.1.69 ā 0.1.71
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 +158 -61
- package/cross-machine.js +174 -128
- package/daemon.js +167 -57
- package/interactive-terminal.js +46 -1
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const { constants: osConstants } = require('os');
|
|
6
7
|
const WebSocket = require('ws');
|
|
7
8
|
const { execSync, spawn } = require('child_process');
|
|
8
9
|
const readline = require('readline');
|
|
@@ -11,7 +12,7 @@ const updateNotifier = require('update-notifier');
|
|
|
11
12
|
const pkg = require('./package.json');
|
|
12
13
|
const { getConfig } = require('./auth');
|
|
13
14
|
const { cleanupDaemonProcesses } = require('./daemon-control');
|
|
14
|
-
const { attachInteractiveTerminal, getTerminalSize } = require('./interactive-terminal');
|
|
15
|
+
const { attachInteractiveTerminal, getTerminalSize, restoreTerminalModes } = require('./interactive-terminal');
|
|
15
16
|
const { getRuntimeInfo } = require('./runtime-info');
|
|
16
17
|
const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
|
|
17
18
|
const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
@@ -43,9 +44,14 @@ function resetInteractiveInput(stream = process.stdin) {
|
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
if (stream.isTTY && (stream.isRaw || stream.__teleptyRawModeActive)) {
|
|
48
|
+
restoreTerminalModes(process.stdout);
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
if (stream.isTTY && typeof stream.setRawMode === 'function') {
|
|
47
52
|
try {
|
|
48
53
|
stream.setRawMode(false);
|
|
54
|
+
stream.__teleptyRawModeActive = false;
|
|
49
55
|
} catch {
|
|
50
56
|
// Ignore raw-mode reset failures when the TTY is already gone.
|
|
51
57
|
}
|
|
@@ -101,6 +107,10 @@ process.stdin.on('error', (error) => {
|
|
|
101
107
|
process.stderr.write(`\nā Telepty stdin error: ${error.message}\n`);
|
|
102
108
|
});
|
|
103
109
|
|
|
110
|
+
process.on('exit', () => {
|
|
111
|
+
resetInteractiveInput(process.stdin);
|
|
112
|
+
});
|
|
113
|
+
|
|
104
114
|
// Check for updates unless explicitly disabled for tests/CI.
|
|
105
115
|
if (!process.env.NO_UPDATE_NOTIFIER && !process.env.TELEPTY_DISABLE_UPDATE_NOTIFIER) {
|
|
106
116
|
updateNotifier({pkg}).notify({ isGlobal: true });
|
|
@@ -196,61 +206,46 @@ async function repairLocalDaemon(options = {}) {
|
|
|
196
206
|
|
|
197
207
|
function getDiscoveryHosts() {
|
|
198
208
|
const hosts = new Set();
|
|
199
|
-
|
|
200
209
|
if (REMOTE_HOST && REMOTE_HOST !== '127.0.0.1') {
|
|
201
210
|
hosts.add(REMOTE_HOST);
|
|
202
211
|
} else {
|
|
203
212
|
hosts.add('127.0.0.1');
|
|
204
213
|
}
|
|
205
|
-
|
|
206
|
-
const extraHosts = String(process.env.TELEPTY_DISCOVERY_HOSTS || '')
|
|
207
|
-
.split(',')
|
|
208
|
-
.map((host) => host.trim())
|
|
209
|
-
.filter(Boolean);
|
|
210
|
-
extraHosts.forEach((host) => hosts.add(host));
|
|
211
|
-
|
|
212
|
-
// Include relay peers for cross-machine session discovery
|
|
213
|
-
const relayPeers = String(process.env.TELEPTY_RELAY_PEERS || '')
|
|
214
|
-
.split(',')
|
|
215
|
-
.map((host) => host.trim())
|
|
216
|
-
.filter(Boolean);
|
|
217
|
-
relayPeers.forEach((host) => hosts.add(host));
|
|
218
|
-
|
|
219
|
-
// Include SSH tunnel-connected peers
|
|
220
|
-
const connectedHosts = crossMachine.getConnectedHosts();
|
|
221
|
-
connectedHosts.forEach((host) => hosts.add(host));
|
|
222
|
-
|
|
223
214
|
return Array.from(hosts);
|
|
224
215
|
}
|
|
225
216
|
|
|
226
217
|
async function discoverSessions(options = {}) {
|
|
227
218
|
await ensureDaemonRunning();
|
|
228
|
-
const hosts = getDiscoveryHosts();
|
|
229
219
|
const allSessions = [];
|
|
230
220
|
|
|
231
221
|
if (!options.silent) {
|
|
232
222
|
process.stdout.write('\x1b[36mš Discovering active sessions across connected machines...\x1b[0m\n');
|
|
233
223
|
}
|
|
234
224
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
225
|
+
// Local daemon sessions
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetchWithAuth(`http://127.0.0.1:${PORT}/api/sessions`, {
|
|
228
|
+
signal: AbortSignal.timeout(1500)
|
|
229
|
+
});
|
|
230
|
+
if (res.ok) {
|
|
231
|
+
const sessions = await res.json();
|
|
232
|
+
sessions.forEach((session) => {
|
|
233
|
+
allSessions.push({ host: '127.0.0.1', ...session });
|
|
239
234
|
});
|
|
240
|
-
if (res.ok) {
|
|
241
|
-
const sessions = await res.json();
|
|
242
|
-
sessions.forEach((session) => {
|
|
243
|
-
allSessions.push({ host, ...session });
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
} catch (e) {
|
|
247
|
-
// Ignore nodes that don't have telepty running
|
|
248
235
|
}
|
|
249
|
-
}
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
// Remote peer sessions via SSH direct
|
|
239
|
+
const remoteSessions = crossMachine.discoverAllRemoteSessions();
|
|
240
|
+
allSessions.push(...remoteSessions);
|
|
250
241
|
|
|
251
242
|
return allSessions;
|
|
252
243
|
}
|
|
253
244
|
|
|
245
|
+
function isRemoteSession(session) {
|
|
246
|
+
return session.remote === true || (session.host && session.host !== '127.0.0.1' && session.host.includes('@'));
|
|
247
|
+
}
|
|
248
|
+
|
|
254
249
|
async function resolveSessionTarget(sessionRef, options = {}) {
|
|
255
250
|
const sessions = options.sessions || await discoverSessions({ silent: true });
|
|
256
251
|
return pickSessionTarget(sessionRef, sessions, REMOTE_HOST);
|
|
@@ -729,12 +724,21 @@ async function main() {
|
|
|
729
724
|
};
|
|
730
725
|
const cmdBase = path.basename(command).replace(/\..*$/, '');
|
|
731
726
|
const promptPattern = PROMPT_PATTERNS[cmdBase] || /[āÆ>$#%]\s*$/;
|
|
732
|
-
let promptReady =
|
|
727
|
+
let promptReady = false; // wait for CLI prompt before accepting inject
|
|
733
728
|
const injectQueue = [];
|
|
729
|
+
let lastUserInputTime = 0; // timestamp of last user keystroke
|
|
730
|
+
const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
|
|
731
|
+
|
|
732
|
+
function isIdle() {
|
|
733
|
+
return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
|
|
734
|
+
}
|
|
734
735
|
|
|
735
736
|
let queueFlushTimer = null;
|
|
737
|
+
let idleCheckTimer = null;
|
|
738
|
+
|
|
736
739
|
function flushInjectQueue() {
|
|
737
740
|
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
741
|
+
if (idleCheckTimer) { clearInterval(idleCheckTimer); idleCheckTimer = null; }
|
|
738
742
|
if (injectQueue.length === 0) return;
|
|
739
743
|
const batch = injectQueue.splice(0);
|
|
740
744
|
let delay = 0;
|
|
@@ -744,14 +748,23 @@ async function main() {
|
|
|
744
748
|
}
|
|
745
749
|
promptReady = false;
|
|
746
750
|
}
|
|
747
|
-
function
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (injectQueue.length > 0) {
|
|
751
|
+
function scheduleIdleFlush() {
|
|
752
|
+
if (idleCheckTimer) return;
|
|
753
|
+
// Poll every 500ms for idle state
|
|
754
|
+
idleCheckTimer = setInterval(() => {
|
|
755
|
+
if (isIdle() && injectQueue.length > 0) {
|
|
752
756
|
flushInjectQueue();
|
|
753
757
|
}
|
|
754
|
-
},
|
|
758
|
+
}, 500);
|
|
759
|
+
// Safety: flush after 15s regardless (prevent stuck queue)
|
|
760
|
+
if (!queueFlushTimer) {
|
|
761
|
+
queueFlushTimer = setTimeout(() => {
|
|
762
|
+
queueFlushTimer = null;
|
|
763
|
+
if (injectQueue.length > 0) {
|
|
764
|
+
flushInjectQueue();
|
|
765
|
+
}
|
|
766
|
+
}, 15000);
|
|
767
|
+
}
|
|
755
768
|
}
|
|
756
769
|
|
|
757
770
|
// Connect to daemon WebSocket with auto-reconnect
|
|
@@ -793,6 +806,10 @@ async function main() {
|
|
|
793
806
|
// No resize trick on reconnect ā it causes visible flickering across all
|
|
794
807
|
// terminals when the daemon restarts and multiple sessions reconnect at once.
|
|
795
808
|
reconnectAttempts = 0;
|
|
809
|
+
// Re-send ready on reconnect so new daemon knows CLI is ready
|
|
810
|
+
if (readyNotified && promptReady) {
|
|
811
|
+
daemonWs.send(JSON.stringify({ type: 'ready' }));
|
|
812
|
+
}
|
|
796
813
|
});
|
|
797
814
|
|
|
798
815
|
daemonWs.on('message', (message) => {
|
|
@@ -807,15 +824,18 @@ async function main() {
|
|
|
807
824
|
injectQueue.push(msg.data);
|
|
808
825
|
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
809
826
|
flushInjectQueue();
|
|
810
|
-
} else if (isCr
|
|
827
|
+
} else if (isCr && isIdle()) {
|
|
828
|
+
// CR when idle ā write immediately
|
|
811
829
|
child.write(msg.data);
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
830
|
+
} else if (!isCr && isIdle()) {
|
|
831
|
+
// Text when idle ā write immediately
|
|
832
|
+
child.write(msg.data);
|
|
833
|
+
promptReady = false;
|
|
834
|
+
lastInjectTextTime = Date.now();
|
|
816
835
|
} else {
|
|
836
|
+
// Not idle (user typing or CLI busy) ā queue for safe delivery
|
|
817
837
|
injectQueue.push(msg.data);
|
|
818
|
-
|
|
838
|
+
scheduleIdleFlush();
|
|
819
839
|
}
|
|
820
840
|
} else if (msg.type === 'resize') {
|
|
821
841
|
child.resize(msg.cols, msg.rows);
|
|
@@ -856,6 +876,7 @@ async function main() {
|
|
|
856
876
|
|
|
857
877
|
const cleanupTerminal = attachInteractiveTerminal(process.stdin, process.stdout, {
|
|
858
878
|
onData: (data) => {
|
|
879
|
+
lastUserInputTime = Date.now();
|
|
859
880
|
child.write(data.toString());
|
|
860
881
|
},
|
|
861
882
|
onResize: () => {
|
|
@@ -863,6 +884,31 @@ async function main() {
|
|
|
863
884
|
child.resize(size.cols, size.rows);
|
|
864
885
|
}
|
|
865
886
|
});
|
|
887
|
+
let allowSessionClosed = false;
|
|
888
|
+
const allowSignalHandlers = new Map();
|
|
889
|
+
|
|
890
|
+
function closeAllowSession() {
|
|
891
|
+
if (allowSessionClosed) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
allowSessionClosed = true;
|
|
896
|
+
cleanupTerminal();
|
|
897
|
+
process.stdout.write(`\x1b]0;\x07`);
|
|
898
|
+
fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => {});
|
|
899
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
900
|
+
try {
|
|
901
|
+
daemonWs.close();
|
|
902
|
+
} catch {}
|
|
903
|
+
for (const [signalName, handler] of allowSignalHandlers) {
|
|
904
|
+
process.off(signalName, handler);
|
|
905
|
+
}
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function exitAllowSession(code) {
|
|
910
|
+
setTimeout(() => process.exit(code), 25);
|
|
911
|
+
}
|
|
866
912
|
|
|
867
913
|
// Intercept terminal title escape sequences and prefix with session ID
|
|
868
914
|
const titlePrefix = `\u26A1 ${sessionId}`;
|
|
@@ -874,6 +920,7 @@ async function main() {
|
|
|
874
920
|
}
|
|
875
921
|
|
|
876
922
|
// Relay PTY output to current terminal + send to daemon for attach clients
|
|
923
|
+
let readyNotified = false;
|
|
877
924
|
child.onData((data) => {
|
|
878
925
|
const rewritten = rewriteTitleSequences(data);
|
|
879
926
|
process.stdout.write(rewritten);
|
|
@@ -884,22 +931,36 @@ async function main() {
|
|
|
884
931
|
if (promptPattern.test(data)) {
|
|
885
932
|
promptReady = true;
|
|
886
933
|
flushInjectQueue();
|
|
934
|
+
// Notify daemon that CLI is ready for inject
|
|
935
|
+
if (!readyNotified && wsReady && daemonWs.readyState === 1) {
|
|
936
|
+
readyNotified = true;
|
|
937
|
+
daemonWs.send(JSON.stringify({ type: 'ready' }));
|
|
938
|
+
}
|
|
887
939
|
}
|
|
888
940
|
});
|
|
889
941
|
|
|
890
942
|
// Handle child exit
|
|
891
943
|
child.onExit(({ exitCode }) => {
|
|
892
|
-
|
|
893
|
-
|
|
944
|
+
if (!closeAllowSession()) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
894
947
|
console.log(`\n\x1b[33mSession '${sessionId}' exited (code ${exitCode}).\x1b[0m`);
|
|
895
|
-
|
|
896
|
-
// Deregister from daemon
|
|
897
|
-
fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => {});
|
|
898
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
899
|
-
daemonWs.close();
|
|
900
|
-
process.exit(exitCode || 0);
|
|
948
|
+
exitAllowSession(exitCode || 0);
|
|
901
949
|
});
|
|
902
950
|
|
|
951
|
+
for (const signalName of ['SIGTERM', 'SIGHUP', 'SIGQUIT']) {
|
|
952
|
+
const handler = () => {
|
|
953
|
+
closeAllowSession();
|
|
954
|
+
try {
|
|
955
|
+
child.kill(signalName);
|
|
956
|
+
} catch {}
|
|
957
|
+
const signalCode = osConstants.signals[signalName] || 1;
|
|
958
|
+
exitAllowSession(128 + signalCode);
|
|
959
|
+
};
|
|
960
|
+
allowSignalHandlers.set(signalName, handler);
|
|
961
|
+
process.on(signalName, handler);
|
|
962
|
+
}
|
|
963
|
+
|
|
903
964
|
// Graceful shutdown on SIGINT (let child handle it via PTY)
|
|
904
965
|
process.on('SIGINT', () => {});
|
|
905
966
|
|
|
@@ -1011,6 +1072,34 @@ async function main() {
|
|
|
1011
1072
|
return;
|
|
1012
1073
|
}
|
|
1013
1074
|
|
|
1075
|
+
if (cmd === 'read-screen') {
|
|
1076
|
+
const sessionId = args[1];
|
|
1077
|
+
if (!sessionId) { console.error('ā Usage: telepty read-screen <session_id> [--lines N] [--raw]'); process.exit(1); }
|
|
1078
|
+
|
|
1079
|
+
const linesIndex = args.indexOf('--lines');
|
|
1080
|
+
const lines = (linesIndex !== -1 && args[linesIndex + 1]) ? args[linesIndex + 1] : '50';
|
|
1081
|
+
const raw = args.includes('--raw');
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
const target = await resolveSessionTarget(sessionId);
|
|
1085
|
+
if (!target) {
|
|
1086
|
+
console.error(`ā Session '${sessionId}' was not found on any discovered host.`);
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
|
|
1091
|
+
const data = await res.json();
|
|
1092
|
+
if (!res.ok) { console.error(`ā Error: ${data.error}`); process.exit(1); }
|
|
1093
|
+
|
|
1094
|
+
if (!data.screen) {
|
|
1095
|
+
console.log('(empty screen)');
|
|
1096
|
+
} else {
|
|
1097
|
+
console.log(data.screen);
|
|
1098
|
+
}
|
|
1099
|
+
} catch (e) { console.error(`ā ${e.message || 'Failed to connect to the target daemon.'}`); }
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1014
1103
|
if (cmd === 'inject') {
|
|
1015
1104
|
// Check for --no-enter flag
|
|
1016
1105
|
const noEnterIndex = args.indexOf('--no-enter');
|
|
@@ -1049,14 +1138,24 @@ async function main() {
|
|
|
1049
1138
|
process.exit(1);
|
|
1050
1139
|
}
|
|
1051
1140
|
|
|
1052
|
-
//
|
|
1053
|
-
if (target
|
|
1141
|
+
// Remote session: use SSH direct execution
|
|
1142
|
+
if (isRemoteSession(target)) {
|
|
1054
1143
|
const { checkEntitlement } = require('./entitlement');
|
|
1055
1144
|
const ent = checkEntitlement({ feature: 'telepty.remote_sessions' });
|
|
1056
1145
|
if (!ent.allowed) {
|
|
1057
1146
|
console.error(`ā ļø ${ent.reason}\n Upgrade: ${ent.upgrade_url}`);
|
|
1058
1147
|
process.exit(1);
|
|
1059
1148
|
}
|
|
1149
|
+
const result = crossMachine.remoteInject(target.peerName, target.id, prompt, {
|
|
1150
|
+
from: fromId,
|
|
1151
|
+
no_enter: noEnter
|
|
1152
|
+
});
|
|
1153
|
+
if (result.success) {
|
|
1154
|
+
console.log(`ā
Context injected successfully into '\x1b[36m${target.id}\x1b[0m' @ ${target.peerName}.`);
|
|
1155
|
+
} else {
|
|
1156
|
+
console.error(`ā Error: ${result.error}`);
|
|
1157
|
+
}
|
|
1158
|
+
return;
|
|
1060
1159
|
}
|
|
1061
1160
|
|
|
1062
1161
|
const body = { prompt, no_enter: noEnter };
|
|
@@ -1069,8 +1168,7 @@ async function main() {
|
|
|
1069
1168
|
});
|
|
1070
1169
|
const data = await res.json();
|
|
1071
1170
|
if (!res.ok) { console.error(`ā Error: ${data.error}`); return; }
|
|
1072
|
-
|
|
1073
|
-
console.log(`ā
Context injected successfully into '\x1b[36m${target.id}\x1b[0m'${hostSuffix}.`);
|
|
1171
|
+
console.log(`ā
Context injected successfully into '\x1b[36m${target.id}\x1b[0m'.`);
|
|
1074
1172
|
} catch (e) { console.error(`ā ${e.message || 'Failed to connect to the target daemon.'}`); }
|
|
1075
1173
|
return;
|
|
1076
1174
|
}
|
|
@@ -1881,8 +1979,6 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
1881
1979
|
if (result.success) {
|
|
1882
1980
|
console.log(`\x1b[32mā
Connected to ${result.name}\x1b[0m`);
|
|
1883
1981
|
console.log(` Machine ID: ${result.machineId}`);
|
|
1884
|
-
console.log(` Local port: ${result.localPort}`);
|
|
1885
|
-
console.log(` Version: ${result.version}`);
|
|
1886
1982
|
console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
|
|
1887
1983
|
} else {
|
|
1888
1984
|
console.error(`\x1b[31mā ${result.error}\x1b[0m`);
|
|
@@ -2023,6 +2119,7 @@ Usage:
|
|
|
2023
2119
|
telepty list List all active sessions across discovered hosts
|
|
2024
2120
|
telepty attach [id[@host]] Attach to a session (Interactive picker if no ID)
|
|
2025
2121
|
telepty inject [--no-enter] [--from <id>] [--reply-to <id>] <id[@host]> "<prompt>" Inject text into a single session
|
|
2122
|
+
telepty read-screen <id[@host]> [--lines N] [--raw] Read session screen buffer
|
|
2026
2123
|
telepty reply "<text>" Reply to the session that last injected into $TELEPTY_SESSION_ID
|
|
2027
2124
|
telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
|
|
2028
2125
|
telepty broadcast "<prompt>" Inject text into ALL active sessions
|
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
|
|
9
|
+
const CONTROL_DIR = path.join(os.homedir(), '.telepty', 'ssh');
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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];
|
|
42
|
+
const name = options.name || target.split('@').pop().split('.')[0];
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
70
|
+
// Verify remote telepty is available
|
|
71
|
+
let machineId = name;
|
|
105
72
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
155
|
-
if (!
|
|
115
|
+
const peer = activePeers.get(name);
|
|
116
|
+
if (!peer) {
|
|
156
117
|
return { success: false, error: `Not connected to ${name}` };
|
|
157
118
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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 = [...
|
|
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 [...
|
|
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
|
-
*
|
|
186
|
-
* @returns {
|
|
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 [
|
|
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
|
-
|
|
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);
|
|
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
|
};
|
package/daemon.js
CHANGED
|
@@ -139,6 +139,17 @@ const sessions = {};
|
|
|
139
139
|
const handoffs = {};
|
|
140
140
|
const threads = {};
|
|
141
141
|
|
|
142
|
+
function appendToOutputRing(session, data) {
|
|
143
|
+
if (!session.outputRing) session.outputRing = [];
|
|
144
|
+
session.outputRing.push(data);
|
|
145
|
+
// Keep total data under ~200KB limit by trimming old entries
|
|
146
|
+
let totalLen = session.outputRing.reduce((sum, d) => sum + d.length, 0);
|
|
147
|
+
while (totalLen > 200000 && session.outputRing.length > 1) {
|
|
148
|
+
totalLen -= session.outputRing[0].length;
|
|
149
|
+
session.outputRing.shift();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
142
153
|
// Detect terminal environment at daemon startup
|
|
143
154
|
const DETECTED_TERMINAL = terminalBackend.detectTerminal();
|
|
144
155
|
console.log(`[DAEMON] Terminal backend: ${DETECTED_TERMINAL}`);
|
|
@@ -152,8 +163,7 @@ for (const [id, meta] of Object.entries(_persisted)) {
|
|
|
152
163
|
command: meta.command || 'wrapped', cwd: meta.cwd || process.cwd(),
|
|
153
164
|
createdAt: meta.createdAt || new Date().toISOString(),
|
|
154
165
|
lastActivityAt: meta.lastActivityAt || new Date().toISOString(),
|
|
155
|
-
clients: new Set(), isClosing: false
|
|
156
|
-
};
|
|
166
|
+
clients: new Set(), isClosing: false, outputRing: [], ready: false, };
|
|
157
167
|
console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
|
|
158
168
|
}
|
|
159
169
|
}
|
|
@@ -261,8 +271,10 @@ app.post('/api/sessions/spawn', (req, res) => {
|
|
|
261
271
|
createdAt: new Date().toISOString(),
|
|
262
272
|
lastActivityAt: new Date().toISOString(),
|
|
263
273
|
clients: new Set(),
|
|
264
|
-
isClosing: false
|
|
265
|
-
|
|
274
|
+
isClosing: false,
|
|
275
|
+
outputRing: [],
|
|
276
|
+
ready: true,
|
|
277
|
+
};
|
|
266
278
|
sessions[session_id] = sessionRecord;
|
|
267
279
|
|
|
268
280
|
// Broadcast session creation to bus
|
|
@@ -284,6 +296,8 @@ app.post('/api/sessions/spawn', (req, res) => {
|
|
|
284
296
|
return;
|
|
285
297
|
}
|
|
286
298
|
|
|
299
|
+
appendToOutputRing(currentSession, data);
|
|
300
|
+
|
|
287
301
|
// Send to direct WS clients
|
|
288
302
|
currentSession.clients.forEach(ws => {
|
|
289
303
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
|
@@ -345,8 +359,10 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
345
359
|
createdAt: new Date().toISOString(),
|
|
346
360
|
lastActivityAt: new Date().toISOString(),
|
|
347
361
|
clients: new Set(),
|
|
348
|
-
isClosing: false
|
|
349
|
-
|
|
362
|
+
isClosing: false,
|
|
363
|
+
outputRing: [],
|
|
364
|
+
ready: false,
|
|
365
|
+
};
|
|
350
366
|
// Check for existing session with same base alias and emit replaced event
|
|
351
367
|
const baseAlias = session_id.replace(/-\d+$/, '');
|
|
352
368
|
const replaced = Object.keys(sessions).find(id => {
|
|
@@ -404,7 +420,8 @@ app.get('/api/sessions', (req, res) => {
|
|
|
404
420
|
createdAt: session.createdAt,
|
|
405
421
|
lastActivityAt: session.lastActivityAt || null,
|
|
406
422
|
idleSeconds,
|
|
407
|
-
active_clients: session.clients.size
|
|
423
|
+
active_clients: session.clients.size,
|
|
424
|
+
ready: session.ready || false
|
|
408
425
|
};
|
|
409
426
|
});
|
|
410
427
|
if (idleGt !== null) {
|
|
@@ -473,8 +490,8 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
|
|
|
473
490
|
const session = sessions[id];
|
|
474
491
|
if (session) {
|
|
475
492
|
try {
|
|
476
|
-
// cmux
|
|
477
|
-
if (
|
|
493
|
+
// cmux per-session backend (text + enter)
|
|
494
|
+
if (session.backend === 'cmux') {
|
|
478
495
|
const ok = terminalBackend.cmuxSendText(id, prompt);
|
|
479
496
|
if (ok) {
|
|
480
497
|
setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
|
|
@@ -546,8 +563,8 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
|
|
|
546
563
|
Object.keys(sessions).forEach(id => {
|
|
547
564
|
const session = sessions[id];
|
|
548
565
|
try {
|
|
549
|
-
// cmux
|
|
550
|
-
if (
|
|
566
|
+
// cmux per-session backend (text + enter)
|
|
567
|
+
if (session.backend === 'cmux') {
|
|
551
568
|
const ok = terminalBackend.cmuxSendText(id, prompt);
|
|
552
569
|
if (ok) {
|
|
553
570
|
setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
|
|
@@ -769,11 +786,10 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
|
|
769
786
|
console.log(`[SUBMIT] Session ${id} (${session.command}) using strategy: ${strategy}`);
|
|
770
787
|
|
|
771
788
|
let success = false;
|
|
772
|
-
// cmux
|
|
773
|
-
if (
|
|
789
|
+
// cmux per-session backend
|
|
790
|
+
if (session.backend === 'cmux') {
|
|
774
791
|
success = terminalBackend.cmuxSendEnter(id);
|
|
775
792
|
}
|
|
776
|
-
// Session-level cmux backend
|
|
777
793
|
if (!success && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
778
794
|
success = submitViaCmux(id);
|
|
779
795
|
}
|
|
@@ -812,11 +828,10 @@ app.post('/api/sessions/submit-all', (req, res) => {
|
|
|
812
828
|
const strategy = getSubmitStrategy(session.command);
|
|
813
829
|
let success = false;
|
|
814
830
|
|
|
815
|
-
// cmux
|
|
816
|
-
if (
|
|
831
|
+
// cmux per-session backend
|
|
832
|
+
if (session.backend === 'cmux') {
|
|
817
833
|
success = terminalBackend.cmuxSendEnter(id);
|
|
818
834
|
}
|
|
819
|
-
// Session-level cmux backend
|
|
820
835
|
if (!success && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
821
836
|
success = submitViaCmux(id);
|
|
822
837
|
}
|
|
@@ -885,28 +900,37 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
885
900
|
if (session.type === 'wrapped') {
|
|
886
901
|
// For wrapped sessions: try cmux send (daemon-level auto-detect),
|
|
887
902
|
// then kitty send-text (bypasses allow bridge queue),
|
|
888
|
-
// then WS as fallback, then submit via
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
903
|
+
// then WS as fallback, then submit via consistent path for CR.
|
|
904
|
+
//
|
|
905
|
+
// When session is NOT ready (CLI hasn't shown prompt yet), skip cmux/kitty
|
|
906
|
+
// because they bypass the allow-bridge's prompt-ready queue.
|
|
907
|
+
// The WS path sends to the allow-bridge which queues until CLI is ready.
|
|
908
|
+
const sock = session.ready ? findKittySocket() : null;
|
|
909
|
+
if (sock && !session.kittyWindowId) session.kittyWindowId = findKittyWindowId(sock, id);
|
|
910
|
+
const wid = session.ready ? session.kittyWindowId : null;
|
|
892
911
|
|
|
893
912
|
let kittyOk = false;
|
|
894
913
|
let cmuxOk = false;
|
|
914
|
+
let deliveryPath = null; // 'cmux', 'kitty', 'ws'
|
|
895
915
|
|
|
896
|
-
// cmux backend: send text directly to surface (
|
|
897
|
-
if (
|
|
916
|
+
// cmux per-session backend: send text directly to surface (only when ready)
|
|
917
|
+
if (session.ready && session.backend === 'cmux') {
|
|
898
918
|
cmuxOk = terminalBackend.cmuxSendText(id, finalPrompt);
|
|
899
|
-
if (cmuxOk)
|
|
919
|
+
if (cmuxOk) {
|
|
920
|
+
deliveryPath = 'cmux';
|
|
921
|
+
console.log(`[INJECT] cmux send for ${id}`);
|
|
922
|
+
}
|
|
900
923
|
}
|
|
901
924
|
|
|
902
925
|
if (!cmuxOk && wid && sock) {
|
|
903
|
-
// Kitty send-text primary (bypasses allow bridge queue)
|
|
926
|
+
// Kitty send-text primary (only when ready ā bypasses allow bridge queue)
|
|
904
927
|
try {
|
|
905
928
|
const escaped = finalPrompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
|
|
906
929
|
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} '${escaped}'`, {
|
|
907
930
|
timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
908
931
|
});
|
|
909
932
|
kittyOk = true;
|
|
933
|
+
deliveryPath = 'kitty';
|
|
910
934
|
console.log(`[INJECT] Kitty send-text for ${id} (window ${wid})`);
|
|
911
935
|
} catch {
|
|
912
936
|
// Invalidate cached window ID ā window may have changed or been closed
|
|
@@ -914,52 +938,75 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
914
938
|
}
|
|
915
939
|
}
|
|
916
940
|
if (!cmuxOk && !kittyOk) {
|
|
917
|
-
//
|
|
941
|
+
// WS path: allow-bridge has its own prompt-ready queue
|
|
918
942
|
const wsOk = writeToSession(finalPrompt);
|
|
919
943
|
if (!wsOk) {
|
|
920
944
|
return res.status(503).json({ error: 'Process not connected' });
|
|
921
945
|
}
|
|
922
|
-
|
|
946
|
+
deliveryPath = 'ws';
|
|
947
|
+
if (!session.ready) {
|
|
948
|
+
console.log(`[INJECT] WS (not ready, allow-bridge will queue) for ${id}`);
|
|
949
|
+
} else {
|
|
950
|
+
console.log(`[INJECT] WS fallback for ${id}`);
|
|
951
|
+
}
|
|
923
952
|
}
|
|
924
953
|
|
|
925
954
|
if (!no_enter) {
|
|
926
955
|
setTimeout(() => {
|
|
927
956
|
let submitted = false;
|
|
928
957
|
|
|
929
|
-
//
|
|
930
|
-
if (
|
|
931
|
-
|
|
932
|
-
if (
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
if (!submitted) {
|
|
943
|
-
const submitStrategy = getSubmitStrategy(session.command);
|
|
944
|
-
const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
|
|
945
|
-
submitted = submitViaOsascript(id, keyCombo);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// 4. Fallback: kitty send-text ā WS
|
|
949
|
-
if (!submitted) {
|
|
950
|
-
console.log(`[INJECT] submit fallback for ${id}`);
|
|
958
|
+
// Use the SAME path that delivered text for CR to guarantee ordering
|
|
959
|
+
if (deliveryPath === 'cmux') {
|
|
960
|
+
// cmux: send-key return via same surface
|
|
961
|
+
if (session.backend === 'cmux') {
|
|
962
|
+
submitted = terminalBackend.cmuxSendEnter(id);
|
|
963
|
+
if (submitted) console.log(`[INJECT] cmux submit for ${id}`);
|
|
964
|
+
}
|
|
965
|
+
if (!submitted && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
966
|
+
submitted = submitViaCmux(id);
|
|
967
|
+
if (submitted) console.log(`[INJECT] cmux session-level submit for ${id}`);
|
|
968
|
+
}
|
|
969
|
+
} else if (deliveryPath === 'kitty') {
|
|
970
|
+
// kitty: send-text CR via same window (not osascript!)
|
|
951
971
|
if (wid && sock) {
|
|
952
972
|
try {
|
|
953
973
|
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
954
974
|
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
955
975
|
});
|
|
976
|
+
submitted = true;
|
|
977
|
+
console.log(`[INJECT] kitty submit for ${id} (window ${wid})`);
|
|
956
978
|
} catch {
|
|
957
|
-
|
|
979
|
+
session.kittyWindowId = null;
|
|
958
980
|
}
|
|
959
|
-
} else {
|
|
960
|
-
writeToSession('\r');
|
|
961
981
|
}
|
|
962
982
|
}
|
|
983
|
+
// deliveryPath === 'ws' or any fallback:
|
|
984
|
+
// Try terminal-level submit first (bypasses PTY ICRNL which converts CRāLF)
|
|
985
|
+
// This matters for cmux/kitty sessions where text went via WS but
|
|
986
|
+
// the application expects CR(13) not LF(10) from Enter.
|
|
987
|
+
if (!submitted && session.backend === 'cmux') {
|
|
988
|
+
submitted = terminalBackend.cmuxSendEnter(id);
|
|
989
|
+
if (submitted) console.log(`[INJECT] cmux submit (fallback) for ${id}`);
|
|
990
|
+
}
|
|
991
|
+
if (!submitted && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
992
|
+
submitted = submitViaCmux(id);
|
|
993
|
+
if (submitted) console.log(`[INJECT] cmux session-level submit (fallback) for ${id}`);
|
|
994
|
+
}
|
|
995
|
+
if (!submitted && wid && sock) {
|
|
996
|
+
try {
|
|
997
|
+
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
998
|
+
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
999
|
+
});
|
|
1000
|
+
submitted = true;
|
|
1001
|
+
console.log(`[INJECT] kitty submit (fallback) for ${id}`);
|
|
1002
|
+
} catch {
|
|
1003
|
+
session.kittyWindowId = null;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (!submitted) {
|
|
1007
|
+
writeToSession('\r');
|
|
1008
|
+
console.log(`[INJECT] WS submit for ${id}`);
|
|
1009
|
+
}
|
|
963
1010
|
|
|
964
1011
|
// Update tab title (kitty-specific, safe to fail)
|
|
965
1012
|
if (wid && sock) {
|
|
@@ -970,7 +1017,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
970
1017
|
} catch {}
|
|
971
1018
|
}
|
|
972
1019
|
}, 500);
|
|
973
|
-
submitResult = { deferred: true, strategy:
|
|
1020
|
+
submitResult = { deferred: true, strategy: deliveryPath || 'ws' };
|
|
974
1021
|
}
|
|
975
1022
|
} else {
|
|
976
1023
|
// Spawned sessions: direct PTY write
|
|
@@ -1042,6 +1089,53 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
1042
1089
|
}
|
|
1043
1090
|
});
|
|
1044
1091
|
|
|
1092
|
+
// GET /api/sessions/:id/screen ā read current screen buffer
|
|
1093
|
+
app.get('/api/sessions/:id/screen', (req, res) => {
|
|
1094
|
+
const requestedId = req.params.id;
|
|
1095
|
+
const resolvedId = resolveSessionAlias(requestedId);
|
|
1096
|
+
if (!resolvedId) return res.status(404).json({ error: 'Session not found', requested: requestedId });
|
|
1097
|
+
const session = sessions[resolvedId];
|
|
1098
|
+
|
|
1099
|
+
const lines = parseInt(req.query.lines) || 50;
|
|
1100
|
+
const raw = req.query.raw === '1' || req.query.raw === 'true';
|
|
1101
|
+
|
|
1102
|
+
if (!session.outputRing || session.outputRing.length === 0) {
|
|
1103
|
+
return res.json({ session_id: resolvedId, screen: '', lines: 0, raw: false });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Join all buffered output
|
|
1107
|
+
const fullOutput = session.outputRing.join('');
|
|
1108
|
+
|
|
1109
|
+
// Strip ANSI escape sequences for clean text
|
|
1110
|
+
function stripAnsi(str) {
|
|
1111
|
+
return str
|
|
1112
|
+
.replace(/[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
1113
|
+
.replace(/][^]*/g, '') // OSC sequences (BEL terminated)
|
|
1114
|
+
.replace(/][^]*\\/g, '') // OSC sequences (ST terminated)
|
|
1115
|
+
.replace(/[()][AB012]/g, '') // Character set selection
|
|
1116
|
+
.replace(/[>=<]/g, '') // Keypad mode
|
|
1117
|
+
.replace(/[[?]?[0-9;]*[hlsurm]/g, '') // Mode set/reset
|
|
1118
|
+
.replace(/[[0-9;]*[ABCDHJ]/g, '') // Cursor movement
|
|
1119
|
+
.replace(/[[0-9;]*[KG]/g, '') // Line clearing
|
|
1120
|
+
.replace(/\r/g, ''); // Carriage returns
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const cleaned = raw ? fullOutput : stripAnsi(fullOutput);
|
|
1124
|
+
|
|
1125
|
+
// Take last N lines
|
|
1126
|
+
const allLines = cleaned.split('\n');
|
|
1127
|
+
const lastLines = allLines.slice(-lines);
|
|
1128
|
+
const screen = lastLines.join('\n').trim();
|
|
1129
|
+
|
|
1130
|
+
res.json({
|
|
1131
|
+
session_id: resolvedId,
|
|
1132
|
+
screen,
|
|
1133
|
+
lines: lastLines.length,
|
|
1134
|
+
total_lines: allLines.length,
|
|
1135
|
+
raw: !!raw
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1045
1139
|
app.patch('/api/sessions/:id', (req, res) => {
|
|
1046
1140
|
const requestedId = req.params.id;
|
|
1047
1141
|
const resolvedId = resolveSessionAlias(requestedId);
|
|
@@ -1129,8 +1223,8 @@ function busAutoRoute(msg) {
|
|
|
1129
1223
|
const wid = targetSession.kittyWindowId;
|
|
1130
1224
|
let delivered = false;
|
|
1131
1225
|
|
|
1132
|
-
// cmux backend: send text + enter to surface
|
|
1133
|
-
if (!delivered &&
|
|
1226
|
+
// cmux per-session backend: send text + enter to surface
|
|
1227
|
+
if (!delivered && targetSession.backend === 'cmux') {
|
|
1134
1228
|
const textOk = terminalBackend.cmuxSendText(targetId, prompt);
|
|
1135
1229
|
if (textOk) {
|
|
1136
1230
|
setTimeout(() => terminalBackend.cmuxSendEnter(targetId), 500);
|
|
@@ -1541,8 +1635,10 @@ wss.on('connection', (ws, req) => {
|
|
|
1541
1635
|
createdAt: new Date().toISOString(),
|
|
1542
1636
|
lastActivityAt: new Date().toISOString(),
|
|
1543
1637
|
clients: new Set([ws]),
|
|
1544
|
-
isClosing: false
|
|
1545
|
-
|
|
1638
|
+
isClosing: false,
|
|
1639
|
+
outputRing: [],
|
|
1640
|
+
ready: false,
|
|
1641
|
+
};
|
|
1546
1642
|
sessions[sessionId] = autoSession;
|
|
1547
1643
|
console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
|
|
1548
1644
|
// Set tab title via kitty (no \x0c redraw ā it causes flickering on multi-session reconnect)
|
|
@@ -1587,11 +1683,25 @@ wss.on('connection', (ws, req) => {
|
|
|
1587
1683
|
// Owner sending output -> broadcast to other clients + update activity
|
|
1588
1684
|
if (type === 'output') {
|
|
1589
1685
|
activeSession.lastActivityAt = new Date().toISOString();
|
|
1686
|
+
appendToOutputRing(activeSession, data);
|
|
1590
1687
|
activeSession.clients.forEach(client => {
|
|
1591
1688
|
if (client !== ws && client.readyState === 1) {
|
|
1592
1689
|
client.send(JSON.stringify({ type: 'output', data }));
|
|
1593
1690
|
}
|
|
1594
1691
|
});
|
|
1692
|
+
} else if (type === 'ready') {
|
|
1693
|
+
activeSession.ready = true;
|
|
1694
|
+
activeSession.lastActivityAt = new Date().toISOString();
|
|
1695
|
+
console.log(`[READY] Session ${sessionId} CLI is ready for inject`);
|
|
1696
|
+
// Broadcast readiness to bus (cmux/kitty paths now enabled for this session)
|
|
1697
|
+
const readyMsg = JSON.stringify({
|
|
1698
|
+
type: 'session_ready',
|
|
1699
|
+
session_id: sessionId,
|
|
1700
|
+
timestamp: new Date().toISOString()
|
|
1701
|
+
});
|
|
1702
|
+
busClients.forEach(client => {
|
|
1703
|
+
if (client.readyState === 1) client.send(readyMsg);
|
|
1704
|
+
});
|
|
1595
1705
|
}
|
|
1596
1706
|
} else {
|
|
1597
1707
|
// Non-owner client input -> forward to owner as inject
|
package/interactive-terminal.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const TERMINAL_CLEANUP_SEQUENCE = [
|
|
6
|
+
'\x1b[?1049l', // Leave the alternate screen if the child died before cleanup.
|
|
7
|
+
'\x1b[?25h', // Ensure the cursor is visible again.
|
|
8
|
+
'\x1b[?1l', // Disable application cursor keys.
|
|
9
|
+
'\x1b>', // Disable application keypad mode.
|
|
10
|
+
'\x1b[?1000l',
|
|
11
|
+
'\x1b[?1002l',
|
|
12
|
+
'\x1b[?1003l',
|
|
13
|
+
'\x1b[?1004l',
|
|
14
|
+
'\x1b[?1005l',
|
|
15
|
+
'\x1b[?1006l',
|
|
16
|
+
'\x1b[?1007l',
|
|
17
|
+
'\x1b[?1015l',
|
|
18
|
+
'\x1b[<u', // Disable kitty keyboard protocol.
|
|
19
|
+
'\x1b[>4;0m', // Disable modifyOtherKeys.
|
|
20
|
+
'\x1b[?2004l' // Disable bracketed paste.
|
|
21
|
+
].join('');
|
|
22
|
+
|
|
3
23
|
function getTerminalSize(output, fallback = {}) {
|
|
4
24
|
const envCols = Number.parseInt(process.env.COLUMNS || '', 10);
|
|
5
25
|
const envRows = Number.parseInt(process.env.LINES || '', 10);
|
|
@@ -31,10 +51,30 @@ function removeListener(stream, eventName, handler) {
|
|
|
31
51
|
}
|
|
32
52
|
}
|
|
33
53
|
|
|
54
|
+
function restoreTerminalModes(output) {
|
|
55
|
+
if (!output) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (typeof output.fd === 'number') {
|
|
61
|
+
fs.writeSync(output.fd, TERMINAL_CLEANUP_SEQUENCE);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof output.write === 'function') {
|
|
66
|
+
output.write(TERMINAL_CLEANUP_SEQUENCE);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore cleanup failures when the TTY is already gone.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
function attachInteractiveTerminal(input, output, handlers = {}) {
|
|
35
74
|
const { onData = null, onResize = null } = handlers;
|
|
36
75
|
|
|
37
76
|
if (input && input.isTTY && typeof input.setRawMode === 'function') {
|
|
77
|
+
input.__teleptyRawModeActive = true;
|
|
38
78
|
input.setRawMode(true);
|
|
39
79
|
}
|
|
40
80
|
|
|
@@ -57,15 +97,20 @@ function attachInteractiveTerminal(input, output, handlers = {}) {
|
|
|
57
97
|
|
|
58
98
|
if (input && input.isTTY && typeof input.setRawMode === 'function') {
|
|
59
99
|
input.setRawMode(false);
|
|
100
|
+
input.__teleptyRawModeActive = false;
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
if (input && typeof input.pause === 'function') {
|
|
63
104
|
input.pause();
|
|
64
105
|
}
|
|
106
|
+
|
|
107
|
+
restoreTerminalModes(output);
|
|
65
108
|
};
|
|
66
109
|
}
|
|
67
110
|
|
|
68
111
|
module.exports = {
|
|
69
112
|
attachInteractiveTerminal,
|
|
70
|
-
getTerminalSize
|
|
113
|
+
getTerminalSize,
|
|
114
|
+
restoreTerminalModes,
|
|
115
|
+
TERMINAL_CLEANUP_SEQUENCE
|
|
71
116
|
};
|