@dmsdc-ai/aigentry-telepty 0.1.67 → 0.1.69

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
@@ -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) => {
@@ -710,7 +697,8 @@ async function main() {
710
697
  command,
711
698
  cwd: process.cwd(),
712
699
  backend: detectedBackend,
713
- cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
700
+ cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null,
701
+ cmux_surface_id: process.env.CMUX_SURFACE_ID || null
714
702
  })
715
703
  });
716
704
  const data = await res.json();
@@ -789,7 +777,8 @@ async function main() {
789
777
  command,
790
778
  cwd: process.cwd(),
791
779
  backend: detectedBackend,
792
- cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null
780
+ cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null,
781
+ cmux_surface_id: process.env.CMUX_SURFACE_ID || null
793
782
  })
794
783
  });
795
784
  } catch (e) {
@@ -1874,6 +1863,85 @@ Discuss the following topic from your project's perspective. Engage with other s
1874
1863
  return;
1875
1864
  }
1876
1865
 
1866
+ // telepty connect <target> [--name <name>] [--port <port>]
1867
+ if (cmd === 'connect') {
1868
+ const target = args[1];
1869
+ if (!target) {
1870
+ console.error('❌ Usage: telepty connect <user@host> [--name <name>] [--port <port>]');
1871
+ process.exit(1);
1872
+ }
1873
+ const nameFlag = args.indexOf('--name');
1874
+ const portFlag = args.indexOf('--port');
1875
+ const options = {};
1876
+ if (nameFlag !== -1 && args[nameFlag + 1]) options.name = args[nameFlag + 1];
1877
+ if (portFlag !== -1 && args[portFlag + 1]) options.port = Number(args[portFlag + 1]);
1878
+
1879
+ process.stdout.write(`\x1b[36m🔗 Connecting to ${target}...\x1b[0m\n`);
1880
+ const result = await crossMachine.connect(target, options);
1881
+ if (result.success) {
1882
+ console.log(`\x1b[32m✅ Connected to ${result.name}\x1b[0m`);
1883
+ console.log(` Machine ID: ${result.machineId}`);
1884
+ console.log(` Local port: ${result.localPort}`);
1885
+ console.log(` Version: ${result.version}`);
1886
+ console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
1887
+ } else {
1888
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1889
+ process.exit(1);
1890
+ }
1891
+ return;
1892
+ }
1893
+
1894
+ // telepty disconnect [<name> | --all]
1895
+ if (cmd === 'disconnect') {
1896
+ if (args[1] === '--all') {
1897
+ const result = crossMachine.disconnectAll();
1898
+ console.log(`\x1b[32m✅ Disconnected from ${result.disconnected.length} peer(s)\x1b[0m`);
1899
+ } else if (args[1]) {
1900
+ const result = crossMachine.disconnect(args[1]);
1901
+ if (result.success) {
1902
+ console.log(`\x1b[32m✅ Disconnected from ${result.name}\x1b[0m`);
1903
+ } else {
1904
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
1905
+ }
1906
+ } else {
1907
+ console.error('❌ Usage: telepty disconnect <name> | --all');
1908
+ process.exit(1);
1909
+ }
1910
+ return;
1911
+ }
1912
+
1913
+ // telepty peers [--remove <name>]
1914
+ if (cmd === 'peers') {
1915
+ if (args[1] === '--remove' && args[2]) {
1916
+ crossMachine.removePeer(args[2]);
1917
+ console.log(`\x1b[32m✅ Removed peer ${args[2]}\x1b[0m`);
1918
+ return;
1919
+ }
1920
+
1921
+ const active = crossMachine.listActivePeers();
1922
+ const known = crossMachine.listKnownPeers();
1923
+
1924
+ console.log('\x1b[1mConnected Peers:\x1b[0m');
1925
+ if (active.length === 0) {
1926
+ console.log(' (none)');
1927
+ } else {
1928
+ for (const peer of active) {
1929
+ console.log(` \x1b[32m●\x1b[0m ${peer.name} (${peer.target}) → localhost:${peer.localPort} [${peer.machineId}]`);
1930
+ }
1931
+ }
1932
+
1933
+ const knownNames = Object.keys(known);
1934
+ const disconnected = knownNames.filter(n => !active.find(a => a.name === n));
1935
+ if (disconnected.length > 0) {
1936
+ console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
1937
+ for (const name of disconnected) {
1938
+ const p = known[name];
1939
+ console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
1940
+ }
1941
+ }
1942
+ return;
1943
+ }
1944
+
1877
1945
  if (cmd === 'listen' || cmd === 'monitor') {
1878
1946
  await ensureDaemonRunning();
1879
1947
 
@@ -1959,6 +2027,9 @@ Usage:
1959
2027
  telepty multicast <id1[@host],id2[@host]> "<prompt>" Inject text into multiple specific sessions
1960
2028
  telepty broadcast "<prompt>" Inject text into ALL active sessions
1961
2029
  telepty rename <old_id[@host]> <new_id> Rename a session (updates terminal title too)
2030
+ telepty connect <user@host> [--name N] [--port P] Connect to a remote machine via SSH tunnel
2031
+ telepty disconnect <name> | --all Disconnect from a remote machine
2032
+ telepty peers [--remove <name>] List connected and known peers
1962
2033
  telepty listen Listen to the event bus and print JSON to stdout
1963
2034
  telepty monitor Human-readable real-time billboard of bus events
1964
2035
  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
@@ -8,6 +8,7 @@ const { getConfig } = require('./auth');
8
8
  const pkg = require('./package.json');
9
9
  const { claimDaemonState, clearDaemonState } = require('./daemon-control');
10
10
  const { checkEntitlement } = require('./entitlement');
11
+ const terminalBackend = require('./terminal-backend');
11
12
 
12
13
  const config = getConfig();
13
14
  const EXPECTED_TOKEN = config.authToken;
@@ -19,7 +20,7 @@ function persistSessions() {
19
20
  try {
20
21
  const data = {};
21
22
  for (const [id, s] of Object.entries(sessions)) {
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
+ data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, backend: s.backend || null, cmuxWorkspaceId: s.cmuxWorkspaceId || null, cmuxSurfaceId: s.cmuxSurfaceId || null, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
23
24
  }
24
25
  fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
25
26
  fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
@@ -91,14 +92,12 @@ function verifyJwt(token) {
91
92
  function isAllowedPeer(ip) {
92
93
  if (!ip) return false;
93
94
  const cleanIp = ip.replace('::ffff:', '');
94
- // Localhost always allowed
95
+ // Localhost always allowed (includes SSH tunnel traffic)
95
96
  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
97
  // Peer allowlist
99
98
  if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
100
99
  // No allowlist = allow all authenticated
101
- return false;
100
+ return true;
102
101
  }
103
102
 
104
103
  // Authentication Middleware
@@ -106,7 +105,7 @@ app.use((req, res, next) => {
106
105
  const clientIp = req.ip;
107
106
 
108
107
  if (isAllowedPeer(clientIp)) {
109
- return next(); // Trust local, Tailscale, and allowlisted peers
108
+ return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
110
109
  }
111
110
 
112
111
  const token = req.headers['x-telepty-token'] || req.query.token;
@@ -140,6 +139,10 @@ const sessions = {};
140
139
  const handoffs = {};
141
140
  const threads = {};
142
141
 
142
+ // Detect terminal environment at daemon startup
143
+ const DETECTED_TERMINAL = terminalBackend.detectTerminal();
144
+ console.log(`[DAEMON] Terminal backend: ${DETECTED_TERMINAL}`);
145
+
143
146
  // Restore persisted session metadata (wrapped sessions await reconnect)
144
147
  const _persisted = loadPersistedSessions();
145
148
  for (const [id, meta] of Object.entries(_persisted)) {
@@ -306,7 +309,7 @@ app.post('/api/sessions/spawn', (req, res) => {
306
309
  });
307
310
 
308
311
  app.post('/api/sessions/register', (req, res) => {
309
- const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id } = req.body;
312
+ const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id, cmux_surface_id } = req.body;
310
313
  if (!session_id) return res.status(400).json({ error: 'session_id is required' });
311
314
  // Entitlement: check session limit for new registrations
312
315
  if (!sessions[session_id]) {
@@ -324,6 +327,7 @@ app.post('/api/sessions/register', (req, res) => {
324
327
  if (cwd) existing.cwd = cwd;
325
328
  if (backend) existing.backend = backend;
326
329
  if (cmux_workspace_id) existing.cmuxWorkspaceId = cmux_workspace_id;
330
+ if (cmux_surface_id) existing.cmuxSurfaceId = cmux_surface_id;
327
331
  console.log(`[REGISTER] Re-registered session ${session_id} (updated metadata)`);
328
332
  return res.status(200).json({ session_id, type: 'wrapped', command: existing.command, cwd: existing.cwd, reregistered: true });
329
333
  }
@@ -337,6 +341,7 @@ app.post('/api/sessions/register', (req, res) => {
337
341
  cwd,
338
342
  backend: backend || 'kitty',
339
343
  cmuxWorkspaceId: cmux_workspace_id || null,
344
+ cmuxSurfaceId: cmux_surface_id || null,
340
345
  createdAt: new Date().toISOString(),
341
346
  lastActivityAt: new Date().toISOString(),
342
347
  clients: new Set(),
@@ -395,6 +400,7 @@ app.get('/api/sessions', (req, res) => {
395
400
  cwd: session.cwd,
396
401
  backend: session.backend || 'kitty',
397
402
  cmuxWorkspaceId: session.cmuxWorkspaceId || null,
403
+ cmuxSurfaceId: session.cmuxSurfaceId || null,
398
404
  createdAt: session.createdAt,
399
405
  lastActivityAt: session.lastActivityAt || null,
400
406
  idleSeconds,
@@ -438,10 +444,24 @@ app.get('/api/meta', (req, res) => {
438
444
  host: HOST,
439
445
  port: Number(PORT),
440
446
  machine_id: MACHINE_ID,
441
- capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads']
447
+ terminal: DETECTED_TERMINAL,
448
+ capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine']
442
449
  });
443
450
  });
444
451
 
452
+ // Peer management endpoint (for cross-machine module)
453
+ app.get('/api/peers', (req, res) => {
454
+ try {
455
+ const crossMachine = require('./cross-machine');
456
+ res.json({
457
+ active: crossMachine.listActivePeers(),
458
+ known: crossMachine.listKnownPeers()
459
+ });
460
+ } catch {
461
+ res.json({ active: [], known: {} });
462
+ }
463
+ });
464
+
445
465
  app.post('/api/sessions/multicast/inject', (req, res) => {
446
466
  const { session_ids, prompt } = req.body;
447
467
  if (!prompt) return res.status(400).json({ error: 'prompt is required' });
@@ -453,6 +473,27 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
453
473
  const session = sessions[id];
454
474
  if (session) {
455
475
  try {
476
+ // cmux auto-detect at daemon level (text + enter)
477
+ if (DETECTED_TERMINAL === 'cmux') {
478
+ const ok = terminalBackend.cmuxSendText(id, prompt);
479
+ if (ok) {
480
+ setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
481
+ results.successful.push({ id, strategy: 'cmux_auto' });
482
+ // Broadcast injection to bus
483
+ const busMsg = JSON.stringify({
484
+ type: 'injection',
485
+ sender: 'cli',
486
+ target_agent: id,
487
+ content: prompt,
488
+ timestamp: new Date().toISOString()
489
+ });
490
+ busClients.forEach(client => {
491
+ if (client.readyState === 1) client.send(busMsg);
492
+ });
493
+ return; // skip WS path for this session
494
+ }
495
+ }
496
+
456
497
  // Inject text first, then \r separately after delay
457
498
  if (session.type === 'wrapped') {
458
499
  if (session.ownerWs && session.ownerWs.readyState === 1) {
@@ -505,6 +546,16 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
505
546
  Object.keys(sessions).forEach(id => {
506
547
  const session = sessions[id];
507
548
  try {
549
+ // cmux auto-detect at daemon level (text + enter)
550
+ if (DETECTED_TERMINAL === 'cmux') {
551
+ const ok = terminalBackend.cmuxSendText(id, prompt);
552
+ if (ok) {
553
+ setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
554
+ results.successful.push({ id, strategy: 'cmux_auto' });
555
+ return; // skip WS path for this session
556
+ }
557
+ }
558
+
508
559
  // Inject text first, then \r separately after delay
509
560
  if (session.type === 'wrapped') {
510
561
  if (session.ownerWs && session.ownerWs.readyState === 1) {
@@ -718,8 +769,12 @@ app.post('/api/sessions/:id/submit', (req, res) => {
718
769
  console.log(`[SUBMIT] Session ${id} (${session.command}) using strategy: ${strategy}`);
719
770
 
720
771
  let success = false;
721
- // cmux backend takes priority
722
- if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
772
+ // cmux auto-detect at daemon level
773
+ if (DETECTED_TERMINAL === 'cmux') {
774
+ success = terminalBackend.cmuxSendEnter(id);
775
+ }
776
+ // Session-level cmux backend
777
+ if (!success && session.backend === 'cmux' && session.cmuxWorkspaceId) {
723
778
  success = submitViaCmux(id);
724
779
  }
725
780
  if (!success) {
@@ -757,8 +812,12 @@ app.post('/api/sessions/submit-all', (req, res) => {
757
812
  const strategy = getSubmitStrategy(session.command);
758
813
  let success = false;
759
814
 
760
- // cmux backend takes priority
761
- if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
815
+ // cmux auto-detect at daemon level
816
+ if (DETECTED_TERMINAL === 'cmux') {
817
+ success = terminalBackend.cmuxSendEnter(id);
818
+ }
819
+ // Session-level cmux backend
820
+ if (!success && session.backend === 'cmux' && session.cmuxWorkspaceId) {
762
821
  success = submitViaCmux(id);
763
822
  }
764
823
  if (!success) {
@@ -824,14 +883,23 @@ app.post('/api/sessions/:id/inject', (req, res) => {
824
883
 
825
884
  let submitResult = null;
826
885
  if (session.type === 'wrapped') {
827
- // For wrapped sessions: try kitty send-text (bypasses allow bridge queue)
828
- // then WS as fallback, then kitty send-key Return for enter
886
+ // For wrapped sessions: try cmux send (daemon-level auto-detect),
887
+ // then kitty send-text (bypasses allow bridge queue),
888
+ // then WS as fallback, then submit via cmux/osascript/kitty/WS
829
889
  const sock = findKittySocket();
830
890
  if (!session.kittyWindowId && sock) session.kittyWindowId = findKittyWindowId(sock, id);
831
891
  const wid = session.kittyWindowId;
832
892
 
833
893
  let kittyOk = false;
834
- if (wid && sock) {
894
+ let cmuxOk = false;
895
+
896
+ // cmux backend: send text directly to surface (daemon-level auto-detect)
897
+ if (DETECTED_TERMINAL === 'cmux') {
898
+ cmuxOk = terminalBackend.cmuxSendText(id, finalPrompt);
899
+ if (cmuxOk) console.log(`[INJECT] cmux send for ${id}`);
900
+ }
901
+
902
+ if (!cmuxOk && wid && sock) {
835
903
  // Kitty send-text primary (bypasses allow bridge queue)
836
904
  try {
837
905
  const escaped = finalPrompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
@@ -845,7 +913,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
845
913
  session.kittyWindowId = null;
846
914
  }
847
915
  }
848
- if (!kittyOk) {
916
+ if (!cmuxOk && !kittyOk) {
849
917
  // Fallback: WS (works with new allow bridges that have queue flush)
850
918
  const wsOk = writeToSession(finalPrompt);
851
919
  if (!wsOk) {
@@ -858,20 +926,26 @@ app.post('/api/sessions/:id/inject', (req, res) => {
858
926
  setTimeout(() => {
859
927
  let submitted = false;
860
928
 
861
- // 1. cmux backend: use cmux send-key
862
- if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
863
- submitted = submitViaCmux(id);
929
+ // 1. cmux backend: send-key return to surface (daemon-level auto-detect)
930
+ if (DETECTED_TERMINAL === 'cmux') {
931
+ submitted = terminalBackend.cmuxSendEnter(id);
864
932
  if (submitted) console.log(`[INJECT] cmux submit for ${id}`);
865
933
  }
866
934
 
867
- // 2. kitty/default backend: osascript primary
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
868
942
  if (!submitted) {
869
943
  const submitStrategy = getSubmitStrategy(session.command);
870
944
  const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
871
945
  submitted = submitViaOsascript(id, keyCombo);
872
946
  }
873
947
 
874
- // 3. Fallback: kitty send-text → WS
948
+ // 4. Fallback: kitty send-text → WS
875
949
  if (!submitted) {
876
950
  console.log(`[INJECT] submit fallback for ${id}`);
877
951
  if (wid && sock) {
@@ -896,7 +970,7 @@ app.post('/api/sessions/:id/inject', (req, res) => {
896
970
  } catch {}
897
971
  }
898
972
  }, 500);
899
- submitResult = { deferred: true, strategy: session.backend === 'cmux' ? 'cmux_with_fallback' : 'osascript_with_fallback' };
973
+ submitResult = { deferred: true, strategy: DETECTED_TERMINAL === 'cmux' ? 'cmux_auto' : (session.backend === 'cmux' ? 'cmux_with_fallback' : 'osascript_with_fallback') };
900
974
  }
901
975
  } else {
902
976
  // Spawned sessions: direct PTY write
@@ -1049,13 +1123,22 @@ function busAutoRoute(msg) {
1049
1123
  const prompt = (msg.payload && msg.payload.prompt) || msg.content || msg.prompt || JSON.stringify(msg);
1050
1124
  const inject_id = crypto.randomUUID();
1051
1125
 
1052
- // Write to session (kitty primary, WS fallback)
1126
+ // Write to session (cmux auto-detect > kitty > session-level cmux > WS fallback)
1053
1127
  const sock = findKittySocket();
1054
1128
  if (!targetSession.kittyWindowId && sock) targetSession.kittyWindowId = findKittyWindowId(sock, targetId);
1055
1129
  const wid = targetSession.kittyWindowId;
1056
1130
  let delivered = false;
1057
1131
 
1058
- if (wid && sock && targetSession.type === 'wrapped') {
1132
+ // cmux backend: send text + enter to surface (daemon-level auto-detect)
1133
+ if (!delivered && DETECTED_TERMINAL === 'cmux') {
1134
+ const textOk = terminalBackend.cmuxSendText(targetId, prompt);
1135
+ if (textOk) {
1136
+ setTimeout(() => terminalBackend.cmuxSendEnter(targetId), 500);
1137
+ delivered = true;
1138
+ }
1139
+ }
1140
+
1141
+ if (!delivered && wid && sock && targetSession.type === 'wrapped') {
1059
1142
  try {
1060
1143
  const escaped = prompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
1061
1144
  require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} '${escaped}'`, {
@@ -1071,7 +1154,7 @@ function busAutoRoute(msg) {
1071
1154
  delivered = true;
1072
1155
  } catch {}
1073
1156
  }
1074
- // cmux backend: use WS for text, cmux send-key for enter
1157
+ // Session-level cmux backend: use WS for text, cmux send-key for enter
1075
1158
  if (!delivered && targetSession.backend === 'cmux' && targetSession.cmuxWorkspaceId) {
1076
1159
  if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
1077
1160
  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.67",
3
+ "version": "0.1.69",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+
5
+ // Detect terminal environment at daemon level
6
+ function detectTerminal() {
7
+ // 1. cmux: check env var or cmux ping
8
+ if (process.env.CMUX_WORKSPACE_ID) {
9
+ try {
10
+ execSync('cmux ping', { timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
11
+ return 'cmux';
12
+ } catch {}
13
+ }
14
+
15
+ // 2. kitty: check for socket
16
+ try {
17
+ const files = require('fs').readdirSync('/tmp').filter(f => f.startsWith('kitty-sock'));
18
+ if (files.length > 0) return 'kitty';
19
+ } catch {}
20
+
21
+ // 3. headless fallback
22
+ return 'headless';
23
+ }
24
+
25
+ // Cache: sessionId -> surfaceRef
26
+ const surfaceCache = new Map();
27
+ let lastCacheRefresh = 0;
28
+ const CACHE_TTL = 30000; // 30 seconds
29
+
30
+ // Build session -> cmux surface mapping from tab titles
31
+ function refreshSurfaceCache() {
32
+ const now = Date.now();
33
+ if (now - lastCacheRefresh < CACHE_TTL && surfaceCache.size > 0) return;
34
+
35
+ try {
36
+ // Find number of workspaces from list-windows
37
+ const windowsOutput = execSync('cmux list-windows', { timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
38
+ const workspacesMatch = windowsOutput.match(/workspaces=(\d+)/);
39
+ const workspaceCount = workspacesMatch ? parseInt(workspacesMatch[1]) : 10;
40
+
41
+ surfaceCache.clear();
42
+ for (let i = 1; i <= workspaceCount; i++) {
43
+ try {
44
+ const output = execSync(`cmux list-pane-surfaces --workspace workspace:${i}`, {
45
+ timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
46
+ });
47
+ // Parse: "* surface:1 ⚡ telepty :: aigentry-orchestrator-claude [selected]"
48
+ const lines = output.split('\n').filter(l => l.trim());
49
+ for (const line of lines) {
50
+ const surfaceMatch = line.match(/surface:(\d+)/);
51
+ const sessionMatch = line.match(/telepty\s*::\s*(\S+)/);
52
+ if (surfaceMatch && sessionMatch) {
53
+ surfaceCache.set(sessionMatch[1], `surface:${surfaceMatch[1]}`);
54
+ }
55
+ }
56
+ } catch {}
57
+ }
58
+ lastCacheRefresh = now;
59
+ console.log(`[BACKEND] Refreshed cmux surface cache: ${surfaceCache.size} sessions mapped`);
60
+ } catch (err) {
61
+ console.error(`[BACKEND] Failed to refresh surface cache:`, err.message);
62
+ }
63
+ }
64
+
65
+ // Find cmux surface ref for a session
66
+ function findSurface(sessionId) {
67
+ refreshSurfaceCache();
68
+
69
+ // Direct match
70
+ if (surfaceCache.has(sessionId)) return surfaceCache.get(sessionId);
71
+
72
+ // Prefix match (e.g., "aigentry-orchestrator" matches "aigentry-orchestrator-claude")
73
+ for (const [id, ref] of surfaceCache.entries()) {
74
+ if (id.startsWith(sessionId) || sessionId.startsWith(id)) return ref;
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ // Send text to a cmux surface
81
+ function cmuxSendText(sessionId, text) {
82
+ const surface = findSurface(sessionId);
83
+ if (!surface) return false;
84
+
85
+ try {
86
+ // Escape single quotes for shell
87
+ const escaped = text.replace(/'/g, "'\\''");
88
+ execSync(`cmux send --surface ${surface} '${escaped}'`, {
89
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
90
+ });
91
+ console.log(`[BACKEND] cmux send text to ${sessionId} (${surface})`);
92
+ return true;
93
+ } catch (err) {
94
+ console.error(`[BACKEND] cmux send failed for ${sessionId}:`, err.message);
95
+ // Invalidate cache entry
96
+ surfaceCache.delete(sessionId);
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // Send enter key to a cmux surface
102
+ function cmuxSendEnter(sessionId) {
103
+ const surface = findSurface(sessionId);
104
+ if (!surface) return false;
105
+
106
+ try {
107
+ execSync(`cmux send-key --surface ${surface} return`, {
108
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
109
+ });
110
+ console.log(`[BACKEND] cmux send-key return to ${sessionId} (${surface})`);
111
+ return true;
112
+ } catch (err) {
113
+ console.error(`[BACKEND] cmux send-key failed for ${sessionId}:`, err.message);
114
+ surfaceCache.delete(sessionId);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // Invalidate cache for a session (e.g., when surface changes)
120
+ function invalidateCache(sessionId) {
121
+ surfaceCache.delete(sessionId);
122
+ }
123
+
124
+ function clearCache() {
125
+ surfaceCache.clear();
126
+ lastCacheRefresh = 0;
127
+ }
128
+
129
+ module.exports = {
130
+ detectTerminal,
131
+ findSurface,
132
+ cmuxSendText,
133
+ cmuxSendEnter,
134
+ refreshSurfaceCache,
135
+ invalidateCache,
136
+ clearCache
137
+ };