@dmsdc-ai/aigentry-telepty 0.1.66 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/cli.js +111 -21
  2. package/cross-machine.js +221 -0
  3. package/daemon.js +99 -32
  4. package/package.json +1 -1
package/cli.js CHANGED
@@ -15,6 +15,7 @@ const { attachInteractiveTerminal, getTerminalSize } = require('./interactive-te
15
15
  const { getRuntimeInfo } = require('./runtime-info');
16
16
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
17
17
  const { runInteractiveSkillInstaller } = require('./skill-installer');
18
+ const crossMachine = require('./cross-machine');
18
19
  const args = process.argv.slice(2);
19
20
  let pendingTerminalInputError = null;
20
21
  let simulatedPromptErrorInjected = false;
@@ -208,30 +209,16 @@ function getDiscoveryHosts() {
208
209
  .filter(Boolean);
209
210
  extraHosts.forEach((host) => hosts.add(host));
210
211
 
211
- // Also include relay peers for cross-machine session discovery
212
+ // Include relay peers for cross-machine session discovery
212
213
  const relayPeers = String(process.env.TELEPTY_RELAY_PEERS || '')
213
214
  .split(',')
214
215
  .map((host) => host.trim())
215
216
  .filter(Boolean);
216
217
  relayPeers.forEach((host) => hosts.add(host));
217
218
 
218
- if (REMOTE_HOST && REMOTE_HOST !== '127.0.0.1') {
219
- return Array.from(hosts);
220
- }
221
-
222
- try {
223
- const tsStatus = execSync('tailscale status --json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
224
- const tsData = JSON.parse(tsStatus);
225
- if (tsData && tsData.Peer) {
226
- for (const peer of Object.values(tsData.Peer)) {
227
- if (peer.Online && peer.TailscaleIPs && peer.TailscaleIPs.length > 0) {
228
- hosts.add(peer.TailscaleIPs[0]);
229
- }
230
- }
231
- }
232
- } catch (e) {
233
- // Tailscale not available or not running, ignore
234
- }
219
+ // Include SSH tunnel-connected peers
220
+ const connectedHosts = crossMachine.getConnectedHosts();
221
+ connectedHosts.forEach((host) => hosts.add(host));
235
222
 
236
223
  return Array.from(hosts);
237
224
  }
@@ -242,7 +229,7 @@ async function discoverSessions(options = {}) {
242
229
  const allSessions = [];
243
230
 
244
231
  if (!options.silent) {
245
- process.stdout.write('\x1b[36m🔍 Discovering active sessions across your Tailnet...\x1b[0m\n');
232
+ process.stdout.write('\x1b[36m🔍 Discovering active sessions across connected machines...\x1b[0m\n');
246
233
  }
247
234
 
248
235
  await Promise.all(hosts.map(async (host) => {
@@ -691,12 +678,27 @@ async function main() {
691
678
 
692
679
  await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
693
680
 
681
+ // Detect terminal backend for session registration
682
+ function findKittySocketCli() {
683
+ try {
684
+ const files = require('fs').readdirSync('/tmp').filter(f => f.startsWith('kitty-sock'));
685
+ return files.length > 0;
686
+ } catch { return false; }
687
+ }
688
+ const detectedBackend = process.env.CMUX_WORKSPACE_ID ? 'cmux' : (findKittySocketCli() ? 'kitty' : 'pty');
689
+
694
690
  // Register session with daemon
695
691
  try {
696
692
  const res = await fetchWithAuth(`${DAEMON_URL}/api/sessions/register`, {
697
693
  method: 'POST',
698
694
  headers: { 'Content-Type': 'application/json' },
699
- body: JSON.stringify({ session_id: sessionId, command, cwd: process.cwd() })
695
+ body: JSON.stringify({
696
+ session_id: sessionId,
697
+ command,
698
+ cwd: process.cwd(),
699
+ backend: detectedBackend,
700
+ cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
701
+ })
700
702
  });
701
703
  const data = await res.json();
702
704
  if (!res.ok) {
@@ -769,7 +771,13 @@ async function main() {
769
771
  await fetchWithAuth(`${DAEMON_URL}/api/sessions/register`, {
770
772
  method: 'POST',
771
773
  headers: { 'Content-Type': 'application/json' },
772
- body: JSON.stringify({ session_id: sessionId, command, cwd: process.cwd() })
774
+ body: JSON.stringify({
775
+ session_id: sessionId,
776
+ command,
777
+ cwd: process.cwd(),
778
+ backend: detectedBackend,
779
+ cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
780
+ })
773
781
  });
774
782
  } catch (e) {
775
783
  // Registration may fail if session already exists or daemon not ready
@@ -1853,6 +1861,85 @@ Discuss the following topic from your project's perspective. Engage with other s
1853
1861
  return;
1854
1862
  }
1855
1863
 
1864
+ // telepty connect <target> [--name <name>] [--port <port>]
1865
+ if (cmd === 'connect') {
1866
+ const target = args[1];
1867
+ if (!target) {
1868
+ console.error('❌ Usage: telepty connect <user@host> [--name <name>] [--port <port>]');
1869
+ process.exit(1);
1870
+ }
1871
+ const nameFlag = args.indexOf('--name');
1872
+ const portFlag = args.indexOf('--port');
1873
+ const options = {};
1874
+ if (nameFlag !== -1 && args[nameFlag + 1]) options.name = args[nameFlag + 1];
1875
+ if (portFlag !== -1 && args[portFlag + 1]) options.port = Number(args[portFlag + 1]);
1876
+
1877
+ process.stdout.write(`\x1b[36m🔗 Connecting to ${target}...\x1b[0m\n`);
1878
+ const result = await crossMachine.connect(target, options);
1879
+ if (result.success) {
1880
+ console.log(`\x1b[32m✅ Connected to ${result.name}\x1b[0m`);
1881
+ console.log(` Machine ID: ${result.machineId}`);
1882
+ console.log(` Local port: ${result.localPort}`);
1883
+ console.log(` Version: ${result.version}`);
1884
+ console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
1885
+ } else {
1886
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1887
+ process.exit(1);
1888
+ }
1889
+ return;
1890
+ }
1891
+
1892
+ // telepty disconnect [<name> | --all]
1893
+ if (cmd === 'disconnect') {
1894
+ if (args[1] === '--all') {
1895
+ const result = crossMachine.disconnectAll();
1896
+ console.log(`\x1b[32m✅ Disconnected from ${result.disconnected.length} peer(s)\x1b[0m`);
1897
+ } else if (args[1]) {
1898
+ const result = crossMachine.disconnect(args[1]);
1899
+ if (result.success) {
1900
+ console.log(`\x1b[32m✅ Disconnected from ${result.name}\x1b[0m`);
1901
+ } else {
1902
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1903
+ }
1904
+ } else {
1905
+ console.error('❌ Usage: telepty disconnect <name> | --all');
1906
+ process.exit(1);
1907
+ }
1908
+ return;
1909
+ }
1910
+
1911
+ // telepty peers [--remove <name>]
1912
+ if (cmd === 'peers') {
1913
+ if (args[1] === '--remove' && args[2]) {
1914
+ crossMachine.removePeer(args[2]);
1915
+ console.log(`\x1b[32m✅ Removed peer ${args[2]}\x1b[0m`);
1916
+ return;
1917
+ }
1918
+
1919
+ const active = crossMachine.listActivePeers();
1920
+ const known = crossMachine.listKnownPeers();
1921
+
1922
+ console.log('\x1b[1mConnected Peers:\x1b[0m');
1923
+ if (active.length === 0) {
1924
+ console.log(' (none)');
1925
+ } else {
1926
+ for (const peer of active) {
1927
+ console.log(` \x1b[32m●\x1b[0m ${peer.name} (${peer.target}) → localhost:${peer.localPort} [${peer.machineId}]`);
1928
+ }
1929
+ }
1930
+
1931
+ const knownNames = Object.keys(known);
1932
+ const disconnected = knownNames.filter(n => !active.find(a => a.name === n));
1933
+ if (disconnected.length > 0) {
1934
+ console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
1935
+ for (const name of disconnected) {
1936
+ const p = known[name];
1937
+ console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
1938
+ }
1939
+ }
1940
+ return;
1941
+ }
1942
+
1856
1943
  if (cmd === 'listen' || cmd === 'monitor') {
1857
1944
  await ensureDaemonRunning();
1858
1945
 
@@ -1938,6 +2025,9 @@ Usage:
1938
2025
  telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
1939
2026
  telepty broadcast "<prompt>" Inject text into ALL active sessions
1940
2027
  telepty rename <old_id[@host]> <new_id> Rename a session (updates terminal title too)
2028
+ telepty connect <user@host> [--name N] [--port P] Connect to a remote machine via SSH tunnel
2029
+ telepty disconnect <name> | --all Disconnect from a remote machine
2030
+ telepty peers [--remove <name>] List connected and known peers
1941
2031
  telepty listen Listen to the event bus and print JSON to stdout
1942
2032
  telepty monitor Human-readable real-time billboard of bus events
1943
2033
  telepty update Update telepty to the latest version
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ const PEERS_PATH = path.join(os.homedir(), '.telepty', 'peers.json');
9
+ const BASE_LOCAL_PORT = 3849; // tunnels start at this port
10
+
11
+ // In-memory active tunnels
12
+ const activeTunnels = new Map(); // name -> { process, localPort, target, connectedAt, ... }
13
+
14
+ function loadPeers() {
15
+ try {
16
+ if (!fs.existsSync(PEERS_PATH)) return { peers: {} };
17
+ return JSON.parse(fs.readFileSync(PEERS_PATH, 'utf8'));
18
+ } catch { return { peers: {} }; }
19
+ }
20
+
21
+ function savePeers(data) {
22
+ try {
23
+ fs.mkdirSync(path.dirname(PEERS_PATH), { recursive: true });
24
+ fs.writeFileSync(PEERS_PATH, JSON.stringify(data, null, 2));
25
+ } catch {}
26
+ }
27
+
28
+ function getNextLocalPort() {
29
+ const usedPorts = new Set([...activeTunnels.values()].map(t => t.localPort));
30
+ let port = BASE_LOCAL_PORT;
31
+ while (usedPorts.has(port)) port++;
32
+ return port;
33
+ }
34
+
35
+ /**
36
+ * Connect to a remote machine via SSH tunnel.
37
+ * @param {string} target - "user@host" or "host" (uses current user)
38
+ * @param {object} options - { name, port }
39
+ * @returns {Promise<object>} - { success, name, localPort, machineId, version } or { success: false, error }
40
+ */
41
+ async function connect(target, options = {}) {
42
+ const remotePort = options.port || 3848;
43
+ const localPort = getNextLocalPort();
44
+
45
+ // Parse target
46
+ let sshTarget = target;
47
+ if (!target.includes('@')) {
48
+ sshTarget = `${os.userInfo().username}@${target}`;
49
+ }
50
+
51
+ const name = options.name || target.split('@').pop().split('.')[0]; // short hostname
52
+
53
+ // Check if already connected
54
+ if (activeTunnels.has(name)) {
55
+ const existing = activeTunnels.get(name);
56
+ return { success: false, error: `Already connected to ${name} on port ${existing.localPort}` };
57
+ }
58
+
59
+ // Create SSH tunnel
60
+ const tunnel = spawn('ssh', [
61
+ '-N', // No remote command
62
+ '-L', `${localPort}:localhost:${remotePort}`, // Local port forwarding
63
+ '-o', 'ServerAliveInterval=30', // Keep alive
64
+ '-o', 'ServerAliveCountMax=3', // Disconnect after 3 missed keepalives
65
+ '-o', 'ExitOnForwardFailure=yes', // Fail if port forwarding fails
66
+ '-o', 'ConnectTimeout=10', // Connection timeout
67
+ '-o', 'StrictHostKeyChecking=accept-new', // Auto-accept new host keys
68
+ sshTarget
69
+ ], {
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ detached: false
72
+ });
73
+
74
+ // Wait for tunnel to establish or fail
75
+ const result = await new Promise((resolve) => {
76
+ let stderr = '';
77
+ const timeout = setTimeout(() => {
78
+ // If process is still running after 5s, tunnel is up
79
+ if (!tunnel.killed && tunnel.exitCode === null) {
80
+ resolve({ success: true });
81
+ } else {
82
+ resolve({ success: false, error: stderr || 'Connection timeout' });
83
+ }
84
+ }, 5000);
85
+
86
+ tunnel.stderr.on('data', (data) => { stderr += data.toString(); });
87
+ tunnel.on('exit', (code) => {
88
+ clearTimeout(timeout);
89
+ if (code !== 0) {
90
+ resolve({ success: false, error: stderr || `SSH exited with code ${code}` });
91
+ }
92
+ });
93
+ tunnel.on('error', (err) => {
94
+ clearTimeout(timeout);
95
+ resolve({ success: false, error: err.message });
96
+ });
97
+ });
98
+
99
+ if (!result.success) {
100
+ tunnel.kill();
101
+ return result;
102
+ }
103
+
104
+ // Verify remote daemon is accessible through tunnel
105
+ try {
106
+ const res = await fetch(`http://127.0.0.1:${localPort}/api/meta`, {
107
+ signal: AbortSignal.timeout(3000)
108
+ });
109
+ if (!res.ok) throw new Error('Daemon not responding');
110
+ const meta = await res.json();
111
+
112
+ const peerInfo = {
113
+ process: tunnel,
114
+ localPort,
115
+ target: sshTarget,
116
+ name,
117
+ machineId: meta.machine_id || name,
118
+ version: meta.version || 'unknown',
119
+ connectedAt: new Date().toISOString()
120
+ };
121
+
122
+ activeTunnels.set(name, peerInfo);
123
+
124
+ // Persist peer for reconnection
125
+ const peers = loadPeers();
126
+ peers.peers[name] = {
127
+ target: sshTarget,
128
+ remotePort,
129
+ lastConnected: peerInfo.connectedAt,
130
+ machineId: peerInfo.machineId
131
+ };
132
+ savePeers(peers);
133
+
134
+ // Monitor tunnel health
135
+ tunnel.on('exit', () => {
136
+ console.log(`[PEER] SSH tunnel to ${name} disconnected`);
137
+ activeTunnels.delete(name);
138
+ });
139
+
140
+ return {
141
+ success: true,
142
+ name,
143
+ localPort,
144
+ machineId: peerInfo.machineId,
145
+ version: peerInfo.version
146
+ };
147
+ } catch (err) {
148
+ tunnel.kill();
149
+ return { success: false, error: `Remote daemon not accessible: ${err.message}` };
150
+ }
151
+ }
152
+
153
+ function disconnect(name) {
154
+ const tunnel = activeTunnels.get(name);
155
+ if (!tunnel) {
156
+ return { success: false, error: `Not connected to ${name}` };
157
+ }
158
+ tunnel.process.kill();
159
+ activeTunnels.delete(name);
160
+ return { success: true, name };
161
+ }
162
+
163
+ function disconnectAll() {
164
+ const names = [...activeTunnels.keys()];
165
+ names.forEach(name => disconnect(name));
166
+ return { disconnected: names };
167
+ }
168
+
169
+ function listActivePeers() {
170
+ return [...activeTunnels.entries()].map(([name, info]) => ({
171
+ name,
172
+ target: info.target,
173
+ localPort: info.localPort,
174
+ machineId: info.machineId,
175
+ connectedAt: info.connectedAt,
176
+ host: `127.0.0.1:${info.localPort}`
177
+ }));
178
+ }
179
+
180
+ function listKnownPeers() {
181
+ return loadPeers().peers;
182
+ }
183
+
184
+ /**
185
+ * Get all connected peer hosts for discovery.
186
+ * @returns {string[]} Array of "127.0.0.1:PORT" strings
187
+ */
188
+ function getConnectedHosts() {
189
+ return [...activeTunnels.values()].map(t => `127.0.0.1:${t.localPort}`);
190
+ }
191
+
192
+ /**
193
+ * Get connected host for a specific peer.
194
+ * @param {string} name - Peer name
195
+ * @returns {string|null} "127.0.0.1:PORT" or null
196
+ */
197
+ function getPeerHost(name) {
198
+ const tunnel = activeTunnels.get(name);
199
+ return tunnel ? `127.0.0.1:${tunnel.localPort}` : null;
200
+ }
201
+
202
+ function removePeer(name) {
203
+ disconnect(name); // disconnect if active
204
+ const peers = loadPeers();
205
+ delete peers.peers[name];
206
+ savePeers(peers);
207
+ return { success: true };
208
+ }
209
+
210
+ module.exports = {
211
+ connect,
212
+ disconnect,
213
+ disconnectAll,
214
+ listActivePeers,
215
+ listKnownPeers,
216
+ getConnectedHosts,
217
+ getPeerHost,
218
+ removePeer,
219
+ loadPeers,
220
+ PEERS_PATH
221
+ };
package/daemon.js CHANGED
@@ -19,7 +19,7 @@ function persistSessions() {
19
19
  try {
20
20
  const data = {};
21
21
  for (const [id, s] of Object.entries(sessions)) {
22
- data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
22
+ data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, backend: s.backend || null, cmuxWorkspaceId: s.cmuxWorkspaceId || null, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
23
23
  }
24
24
  fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
25
25
  fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
@@ -91,14 +91,12 @@ function verifyJwt(token) {
91
91
  function isAllowedPeer(ip) {
92
92
  if (!ip) return false;
93
93
  const cleanIp = ip.replace('::ffff:', '');
94
- // Localhost always allowed
94
+ // Localhost always allowed (includes SSH tunnel traffic)
95
95
  if (cleanIp === '127.0.0.1' || ip === '::1') return true;
96
- // Tailscale range (100.x.y.z)
97
- if (cleanIp.startsWith('100.')) return true;
98
96
  // Peer allowlist
99
97
  if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
100
98
  // No allowlist = allow all authenticated
101
- return false;
99
+ return true;
102
100
  }
103
101
 
104
102
  // Authentication Middleware
@@ -106,7 +104,7 @@ app.use((req, res, next) => {
106
104
  const clientIp = req.ip;
107
105
 
108
106
  if (isAllowedPeer(clientIp)) {
109
- return next(); // Trust local, Tailscale, and allowlisted peers
107
+ return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
110
108
  }
111
109
 
112
110
  const token = req.headers['x-telepty-token'] || req.query.token;
@@ -306,7 +304,7 @@ app.post('/api/sessions/spawn', (req, res) => {
306
304
  });
307
305
 
308
306
  app.post('/api/sessions/register', (req, res) => {
309
- const { session_id, command, cwd = process.cwd() } = req.body;
307
+ const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id } = req.body;
310
308
  if (!session_id) return res.status(400).json({ error: 'session_id is required' });
311
309
  // Entitlement: check session limit for new registrations
312
310
  if (!sessions[session_id]) {
@@ -322,6 +320,8 @@ app.post('/api/sessions/register', (req, res) => {
322
320
  const existing = sessions[session_id];
323
321
  if (command) existing.command = command;
324
322
  if (cwd) existing.cwd = cwd;
323
+ if (backend) existing.backend = backend;
324
+ if (cmux_workspace_id) existing.cmuxWorkspaceId = cmux_workspace_id;
325
325
  console.log(`[REGISTER] Re-registered session ${session_id} (updated metadata)`);
326
326
  return res.status(200).json({ session_id, type: 'wrapped', command: existing.command, cwd: existing.cwd, reregistered: true });
327
327
  }
@@ -333,6 +333,8 @@ app.post('/api/sessions/register', (req, res) => {
333
333
  ownerWs: null,
334
334
  command: command || 'wrapped',
335
335
  cwd,
336
+ backend: backend || 'kitty',
337
+ cmuxWorkspaceId: cmux_workspace_id || null,
336
338
  createdAt: new Date().toISOString(),
337
339
  lastActivityAt: new Date().toISOString(),
338
340
  clients: new Set(),
@@ -389,6 +391,8 @@ app.get('/api/sessions', (req, res) => {
389
391
  type: session.type || 'spawned',
390
392
  command: session.command,
391
393
  cwd: session.cwd,
394
+ backend: session.backend || 'kitty',
395
+ cmuxWorkspaceId: session.cmuxWorkspaceId || null,
392
396
  createdAt: session.createdAt,
393
397
  lastActivityAt: session.lastActivityAt || null,
394
398
  idleSeconds,
@@ -432,10 +436,23 @@ app.get('/api/meta', (req, res) => {
432
436
  host: HOST,
433
437
  port: Number(PORT),
434
438
  machine_id: MACHINE_ID,
435
- capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads']
439
+ capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine']
436
440
  });
437
441
  });
438
442
 
443
+ // Peer management endpoint (for cross-machine module)
444
+ app.get('/api/peers', (req, res) => {
445
+ try {
446
+ const crossMachine = require('./cross-machine');
447
+ res.json({
448
+ active: crossMachine.listActivePeers(),
449
+ known: crossMachine.listKnownPeers()
450
+ });
451
+ } catch {
452
+ res.json({ active: [], known: {} });
453
+ }
454
+ });
455
+
439
456
  app.post('/api/sessions/multicast/inject', (req, res) => {
440
457
  const { session_ids, prompt } = req.body;
441
458
  if (!prompt) return res.status(400).json({ error: 'prompt is required' });
@@ -452,11 +469,13 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
452
469
  if (session.ownerWs && session.ownerWs.readyState === 1) {
453
470
  session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
454
471
  setTimeout(() => {
455
- if (session.ownerWs && session.ownerWs.readyState === 1) {
472
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
473
+ submitViaCmux(id);
474
+ } else if (session.ownerWs && session.ownerWs.readyState === 1) {
456
475
  session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
457
476
  }
458
477
  }, 300);
459
- results.successful.push({ id, strategy: 'split_cr' });
478
+ results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
460
479
  } else {
461
480
  results.failed.push({ id, error: 'Wrap process not connected' });
462
481
  }
@@ -502,11 +521,13 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
502
521
  if (session.ownerWs && session.ownerWs.readyState === 1) {
503
522
  session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
504
523
  setTimeout(() => {
505
- if (session.ownerWs && session.ownerWs.readyState === 1) {
524
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
525
+ submitViaCmux(id);
526
+ } else if (session.ownerWs && session.ownerWs.readyState === 1) {
506
527
  session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
507
528
  }
508
529
  }, 300);
509
- results.successful.push({ id, strategy: 'split_cr' });
530
+ results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
510
531
  } else {
511
532
  results.failed.push({ id, error: 'Wrap process not connected' });
512
533
  }
@@ -680,6 +701,22 @@ function submitViaOsascript(sessionId, keyCombo) {
680
701
  }
681
702
  }
682
703
 
704
+ function submitViaCmux(sessionId) {
705
+ const { execSync } = require('child_process');
706
+ const session = sessions[sessionId];
707
+ if (!session || !session.cmuxWorkspaceId) return false;
708
+ try {
709
+ execSync(`cmux send-key --workspace ${session.cmuxWorkspaceId} return`, {
710
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
711
+ });
712
+ console.log(`[SUBMIT] cmux send-key return for ${sessionId} (workspace ${session.cmuxWorkspaceId})`);
713
+ return true;
714
+ } catch (err) {
715
+ console.error(`[SUBMIT] cmux send-key failed for ${sessionId}:`, err.message);
716
+ return false;
717
+ }
718
+ }
719
+
683
720
  // POST /api/sessions/:id/submit — CLI-aware submit
684
721
  app.post('/api/sessions/:id/submit', (req, res) => {
685
722
  const requestedId = req.params.id;
@@ -692,12 +729,18 @@ app.post('/api/sessions/:id/submit', (req, res) => {
692
729
  console.log(`[SUBMIT] Session ${id} (${session.command}) using strategy: ${strategy}`);
693
730
 
694
731
  let success = false;
695
- if (strategy === 'pty_cr') {
696
- success = submitViaPty(session);
697
- } else if (strategy === 'osascript_cmd_enter') {
698
- success = submitViaOsascript(id, 'cmd_enter');
699
- } else {
700
- success = submitViaPty(session); // fallback
732
+ // cmux backend takes priority
733
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
734
+ success = submitViaCmux(id);
735
+ }
736
+ if (!success) {
737
+ if (strategy === 'pty_cr') {
738
+ success = submitViaPty(session);
739
+ } else if (strategy === 'osascript_cmd_enter') {
740
+ success = submitViaOsascript(id, 'cmd_enter');
741
+ } else {
742
+ success = submitViaPty(session); // fallback
743
+ }
701
744
  }
702
745
 
703
746
  if (success) {
@@ -725,10 +768,16 @@ app.post('/api/sessions/submit-all', (req, res) => {
725
768
  const strategy = getSubmitStrategy(session.command);
726
769
  let success = false;
727
770
 
728
- if (strategy === 'pty_cr') {
729
- success = submitViaPty(session);
730
- } else if (strategy === 'osascript_cmd_enter') {
731
- success = submitViaOsascript(id, 'cmd_enter');
771
+ // cmux backend takes priority
772
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
773
+ success = submitViaCmux(id);
774
+ }
775
+ if (!success) {
776
+ if (strategy === 'pty_cr') {
777
+ success = submitViaPty(session);
778
+ } else if (strategy === 'osascript_cmd_enter') {
779
+ success = submitViaOsascript(id, 'cmd_enter');
780
+ }
732
781
  }
733
782
 
734
783
  if (success) {
@@ -818,14 +867,24 @@ app.post('/api/sessions/:id/inject', (req, res) => {
818
867
 
819
868
  if (!no_enter) {
820
869
  setTimeout(() => {
821
- // Primary: osascript keystroke (reliable across all CLIs)
822
- const submitStrategy = getSubmitStrategy(session.command);
823
- const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
824
- const osascriptOk = submitViaOsascript(id, keyCombo);
825
-
826
- if (!osascriptOk) {
827
- // Fallback: kitty send-text → WS
828
- console.log(`[INJECT] osascript submit failed for ${id}, trying fallback`);
870
+ let submitted = false;
871
+
872
+ // 1. cmux backend: use cmux send-key
873
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
874
+ submitted = submitViaCmux(id);
875
+ if (submitted) console.log(`[INJECT] cmux submit for ${id}`);
876
+ }
877
+
878
+ // 2. kitty/default backend: osascript primary
879
+ if (!submitted) {
880
+ const submitStrategy = getSubmitStrategy(session.command);
881
+ const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
882
+ submitted = submitViaOsascript(id, keyCombo);
883
+ }
884
+
885
+ // 3. Fallback: kitty send-text → WS
886
+ if (!submitted) {
887
+ console.log(`[INJECT] submit fallback for ${id}`);
829
888
  if (wid && sock) {
830
889
  try {
831
890
  require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
@@ -839,7 +898,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
839
898
  }
840
899
  }
841
900
 
842
- // Update tab title regardless of submit method
901
+ // Update tab title (kitty-specific, safe to fail)
843
902
  if (wid && sock) {
844
903
  try {
845
904
  require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${id}'`, {
@@ -848,7 +907,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
848
907
  } catch {}
849
908
  }
850
909
  }, 500);
851
- submitResult = { deferred: true, strategy: 'osascript_with_fallback' };
910
+ submitResult = { deferred: true, strategy: session.backend === 'cmux' ? 'cmux_with_fallback' : 'osascript_with_fallback' };
852
911
  }
853
912
  } else {
854
913
  // Spawned sessions: direct PTY write
@@ -1023,6 +1082,14 @@ function busAutoRoute(msg) {
1023
1082
  delivered = true;
1024
1083
  } catch {}
1025
1084
  }
1085
+ // cmux backend: use WS for text, cmux send-key for enter
1086
+ if (!delivered && targetSession.backend === 'cmux' && targetSession.cmuxWorkspaceId) {
1087
+ if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
1088
+ targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
1089
+ setTimeout(() => submitViaCmux(targetId), 500);
1090
+ delivered = true;
1091
+ }
1092
+ }
1026
1093
  if (!delivered) {
1027
1094
  if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
1028
1095
  targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",