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