@dmsdc-ai/aigentry-telepty 0.1.69 → 0.1.70

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,7 +724,7 @@ 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 = [];
734
729
 
735
730
  let queueFlushTimer = null;
@@ -751,7 +746,7 @@ async function main() {
751
746
  if (injectQueue.length > 0) {
752
747
  flushInjectQueue();
753
748
  }
754
- }, 3000);
749
+ }, 15000);
755
750
  }
756
751
 
757
752
  // Connect to daemon WebSocket with auto-reconnect
@@ -793,6 +788,10 @@ async function main() {
793
788
  // No resize trick on reconnect — it causes visible flickering across all
794
789
  // terminals when the daemon restarts and multiple sessions reconnect at once.
795
790
  reconnectAttempts = 0;
791
+ // Re-send ready on reconnect so new daemon knows CLI is ready
792
+ if (readyNotified && promptReady) {
793
+ daemonWs.send(JSON.stringify({ type: 'ready' }));
794
+ }
796
795
  });
797
796
 
798
797
  daemonWs.on('message', (message) => {
@@ -863,6 +862,31 @@ async function main() {
863
862
  child.resize(size.cols, size.rows);
864
863
  }
865
864
  });
865
+ let allowSessionClosed = false;
866
+ const allowSignalHandlers = new Map();
867
+
868
+ function closeAllowSession() {
869
+ if (allowSessionClosed) {
870
+ return false;
871
+ }
872
+
873
+ allowSessionClosed = true;
874
+ cleanupTerminal();
875
+ process.stdout.write(`\x1b]0;\x07`);
876
+ fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => {});
877
+ if (reconnectTimer) clearTimeout(reconnectTimer);
878
+ try {
879
+ daemonWs.close();
880
+ } catch {}
881
+ for (const [signalName, handler] of allowSignalHandlers) {
882
+ process.off(signalName, handler);
883
+ }
884
+ return true;
885
+ }
886
+
887
+ function exitAllowSession(code) {
888
+ setTimeout(() => process.exit(code), 25);
889
+ }
866
890
 
867
891
  // Intercept terminal title escape sequences and prefix with session ID
868
892
  const titlePrefix = `\u26A1 ${sessionId}`;
@@ -874,6 +898,7 @@ async function main() {
874
898
  }
875
899
 
876
900
  // Relay PTY output to current terminal + send to daemon for attach clients
901
+ let readyNotified = false;
877
902
  child.onData((data) => {
878
903
  const rewritten = rewriteTitleSequences(data);
879
904
  process.stdout.write(rewritten);
@@ -884,22 +909,36 @@ async function main() {
884
909
  if (promptPattern.test(data)) {
885
910
  promptReady = true;
886
911
  flushInjectQueue();
912
+ // Notify daemon that CLI is ready for inject
913
+ if (!readyNotified && wsReady && daemonWs.readyState === 1) {
914
+ readyNotified = true;
915
+ daemonWs.send(JSON.stringify({ type: 'ready' }));
916
+ }
887
917
  }
888
918
  });
889
919
 
890
920
  // Handle child exit
891
921
  child.onExit(({ exitCode }) => {
892
- cleanupTerminal();
893
- process.stdout.write(`\x1b]0;\x07`);
922
+ if (!closeAllowSession()) {
923
+ return;
924
+ }
894
925
  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);
926
+ exitAllowSession(exitCode || 0);
901
927
  });
902
928
 
929
+ for (const signalName of ['SIGTERM', 'SIGHUP', 'SIGQUIT']) {
930
+ const handler = () => {
931
+ closeAllowSession();
932
+ try {
933
+ child.kill(signalName);
934
+ } catch {}
935
+ const signalCode = osConstants.signals[signalName] || 1;
936
+ exitAllowSession(128 + signalCode);
937
+ };
938
+ allowSignalHandlers.set(signalName, handler);
939
+ process.on(signalName, handler);
940
+ }
941
+
903
942
  // Graceful shutdown on SIGINT (let child handle it via PTY)
904
943
  process.on('SIGINT', () => {});
905
944
 
@@ -1011,6 +1050,34 @@ async function main() {
1011
1050
  return;
1012
1051
  }
1013
1052
 
1053
+ if (cmd === 'read-screen') {
1054
+ const sessionId = args[1];
1055
+ if (!sessionId) { console.error('āŒ Usage: telepty read-screen <session_id> [--lines N] [--raw]'); process.exit(1); }
1056
+
1057
+ const linesIndex = args.indexOf('--lines');
1058
+ const lines = (linesIndex !== -1 && args[linesIndex + 1]) ? args[linesIndex + 1] : '50';
1059
+ const raw = args.includes('--raw');
1060
+
1061
+ try {
1062
+ const target = await resolveSessionTarget(sessionId);
1063
+ if (!target) {
1064
+ console.error(`āŒ Session '${sessionId}' was not found on any discovered host.`);
1065
+ process.exit(1);
1066
+ }
1067
+
1068
+ const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
1069
+ const data = await res.json();
1070
+ if (!res.ok) { console.error(`āŒ Error: ${data.error}`); process.exit(1); }
1071
+
1072
+ if (!data.screen) {
1073
+ console.log('(empty screen)');
1074
+ } else {
1075
+ console.log(data.screen);
1076
+ }
1077
+ } catch (e) { console.error(`āŒ ${e.message || 'Failed to connect to the target daemon.'}`); }
1078
+ return;
1079
+ }
1080
+
1014
1081
  if (cmd === 'inject') {
1015
1082
  // Check for --no-enter flag
1016
1083
  const noEnterIndex = args.indexOf('--no-enter');
@@ -1049,14 +1116,24 @@ async function main() {
1049
1116
  process.exit(1);
1050
1117
  }
1051
1118
 
1052
- // Entitlement: remote session check
1053
- if (target.host && target.host !== '127.0.0.1' && target.host !== 'localhost') {
1119
+ // Remote session: use SSH direct execution
1120
+ if (isRemoteSession(target)) {
1054
1121
  const { checkEntitlement } = require('./entitlement');
1055
1122
  const ent = checkEntitlement({ feature: 'telepty.remote_sessions' });
1056
1123
  if (!ent.allowed) {
1057
1124
  console.error(`āš ļø ${ent.reason}\n Upgrade: ${ent.upgrade_url}`);
1058
1125
  process.exit(1);
1059
1126
  }
1127
+ const result = crossMachine.remoteInject(target.peerName, target.id, prompt, {
1128
+ from: fromId,
1129
+ no_enter: noEnter
1130
+ });
1131
+ if (result.success) {
1132
+ console.log(`āœ… Context injected successfully into '\x1b[36m${target.id}\x1b[0m' @ ${target.peerName}.`);
1133
+ } else {
1134
+ console.error(`āŒ Error: ${result.error}`);
1135
+ }
1136
+ return;
1060
1137
  }
1061
1138
 
1062
1139
  const body = { prompt, no_enter: noEnter };
@@ -1069,8 +1146,7 @@ async function main() {
1069
1146
  });
1070
1147
  const data = await res.json();
1071
1148
  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}.`);
1149
+ console.log(`āœ… Context injected successfully into '\x1b[36m${target.id}\x1b[0m'.`);
1074
1150
  } catch (e) { console.error(`āŒ ${e.message || 'Failed to connect to the target daemon.'}`); }
1075
1151
  return;
1076
1152
  }
@@ -1881,8 +1957,6 @@ Discuss the following topic from your project's perspective. Engage with other s
1881
1957
  if (result.success) {
1882
1958
  console.log(`\x1b[32māœ… Connected to ${result.name}\x1b[0m`);
1883
1959
  console.log(` Machine ID: ${result.machineId}`);
1884
- console.log(` Local port: ${result.localPort}`);
1885
- console.log(` Version: ${result.version}`);
1886
1960
  console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
1887
1961
  } else {
1888
1962
  console.error(`\x1b[31māŒ ${result.error}\x1b[0m`);
@@ -2023,6 +2097,7 @@ Usage:
2023
2097
  telepty list List all active sessions across discovered hosts
2024
2098
  telepty attach [id[@host]] Attach to a session (Interactive picker if no ID)
2025
2099
  telepty inject [--no-enter] [--from <id>] [--reply-to <id>] <id[@host]> "<prompt>" Inject text into a single session
2100
+ telepty read-screen <id[@host]> [--lines N] [--raw] Read session screen buffer
2026
2101
  telepty reply "<text>" Reply to the session that last injected into $TELEPTY_SESSION_ID
2027
2102
  telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
2028
2103
  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.70",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",