janus-remote 0.4.0__tar.gz

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.

Potentially problematic release.


This version of janus-remote might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 He Who Seeks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: janus-remote
3
+ Version: 0.4.0
4
+ Summary: Voice-to-text paste bridge for Claude CLI on remote SSH sessions
5
+ Author: He Who Seeks
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/anthropics/janus
8
+ Project-URL: Repository, https://github.com/anthropics/janus
9
+ Keywords: claude,voice,ssh,remote,paste,janus
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: websocket-client>=1.0.0
26
+ Dynamic: license-file
27
+
28
+ # janus-remote
29
+
30
+ Voice-to-text paste bridge for Claude CLI on remote SSH sessions.
31
+
32
+ Use your voice to interact with Claude CLI running on remote servers, with transcriptions pasted directly into the terminal - no window switching needed!
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install janus-remote
38
+ ```
39
+
40
+ ## Requirements
41
+
42
+ 1. **Local Mac**: Janus Electron app running (provides voice recognition + WebSocket server)
43
+ 2. **SSH Config**: Port forwarding enabled (one-time setup)
44
+
45
+ ## SSH Setup (One-Time)
46
+
47
+ Add this to your `~/.ssh/config` on your **local Mac**:
48
+
49
+ ```
50
+ Host *
51
+ RemoteForward 9473 localhost:9473
52
+ ```
53
+
54
+ Or for specific hosts:
55
+
56
+ ```
57
+ Host myserver
58
+ HostName myserver.example.com
59
+ RemoteForward 9473 localhost:9473
60
+ ```
61
+
62
+ This forwards the Janus WebSocket bridge (port 9473) to the remote server.
63
+
64
+ ## Usage
65
+
66
+ On your remote server via SSH:
67
+
68
+ ```bash
69
+ # Start a new Claude session with voice paste support
70
+ claude-janus
71
+
72
+ # Resume a previous session
73
+ claude-janus --resume
74
+ claude-janus -r
75
+ ```
76
+
77
+ ## How It Works
78
+
79
+ ```
80
+ LOCAL MAC REMOTE SERVER (via SSH)
81
+ ┌─────────────────────┐ ┌─────────────────────┐
82
+ │ Janus Electron │ │ claude-janus │
83
+ │ (Voice Recognition) │ │ (This package) │
84
+ │ │ │ │ │ │
85
+ │ ▼ │ │ │ │
86
+ │ WebSocket :9473 ────┼─────────────┼───────► │ │
87
+ │ │ SSH Tunnel │ ▼ │
88
+ │ │ │ Inject into PTY │
89
+ └─────────────────────┘ └─────────────────────┘
90
+ ```
91
+
92
+ 1. Speak into your Mac's microphone
93
+ 2. Janus transcribes and sends via WebSocket
94
+ 3. SSH tunnel forwards to remote server
95
+ 4. `claude-janus` receives and injects text directly into Claude CLI
96
+
97
+ ## Features
98
+
99
+ - **Sexy Terminal Banner**: Claude + Janus ASCII art on startup 🔮
100
+ - **Voice Paste**: Speak on your Mac → text appears in remote terminal
101
+ - **Approval Overlay**: Claude permission requests show on your local Mac overlay
102
+ - **Zero latency feel**: WebSocket connection, no polling
103
+ - **Background paste**: No window switching - text appears directly in terminal
104
+ - **Multi-session support**: Run multiple `claude-janus` sessions on different servers
105
+ - **Auto-reconnect**: Handles connection drops gracefully
106
+
107
+ ## Troubleshooting
108
+
109
+ ### "Bridge connection failed"
110
+ - Ensure Janus Electron is running on your local Mac
111
+ - Verify SSH port forwarding is configured
112
+ - Check that port 9473 isn't blocked
113
+
114
+ ### "Could not find 'claude' binary"
115
+ - Install Claude CLI: `npm install -g @anthropic-ai/claude-cli`
116
+ - Or ensure it's in your PATH
117
+
118
+ ## License
119
+
120
+ MIT
121
+
122
+ ## Author
123
+
124
+ He Who Seeks
@@ -0,0 +1,97 @@
1
+ # janus-remote
2
+
3
+ Voice-to-text paste bridge for Claude CLI on remote SSH sessions.
4
+
5
+ Use your voice to interact with Claude CLI running on remote servers, with transcriptions pasted directly into the terminal - no window switching needed!
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install janus-remote
11
+ ```
12
+
13
+ ## Requirements
14
+
15
+ 1. **Local Mac**: Janus Electron app running (provides voice recognition + WebSocket server)
16
+ 2. **SSH Config**: Port forwarding enabled (one-time setup)
17
+
18
+ ## SSH Setup (One-Time)
19
+
20
+ Add this to your `~/.ssh/config` on your **local Mac**:
21
+
22
+ ```
23
+ Host *
24
+ RemoteForward 9473 localhost:9473
25
+ ```
26
+
27
+ Or for specific hosts:
28
+
29
+ ```
30
+ Host myserver
31
+ HostName myserver.example.com
32
+ RemoteForward 9473 localhost:9473
33
+ ```
34
+
35
+ This forwards the Janus WebSocket bridge (port 9473) to the remote server.
36
+
37
+ ## Usage
38
+
39
+ On your remote server via SSH:
40
+
41
+ ```bash
42
+ # Start a new Claude session with voice paste support
43
+ claude-janus
44
+
45
+ # Resume a previous session
46
+ claude-janus --resume
47
+ claude-janus -r
48
+ ```
49
+
50
+ ## How It Works
51
+
52
+ ```
53
+ LOCAL MAC REMOTE SERVER (via SSH)
54
+ ┌─────────────────────┐ ┌─────────────────────┐
55
+ │ Janus Electron │ │ claude-janus │
56
+ │ (Voice Recognition) │ │ (This package) │
57
+ │ │ │ │ │ │
58
+ │ ▼ │ │ │ │
59
+ │ WebSocket :9473 ────┼─────────────┼───────► │ │
60
+ │ │ SSH Tunnel │ ▼ │
61
+ │ │ │ Inject into PTY │
62
+ └─────────────────────┘ └─────────────────────┘
63
+ ```
64
+
65
+ 1. Speak into your Mac's microphone
66
+ 2. Janus transcribes and sends via WebSocket
67
+ 3. SSH tunnel forwards to remote server
68
+ 4. `claude-janus` receives and injects text directly into Claude CLI
69
+
70
+ ## Features
71
+
72
+ - **Sexy Terminal Banner**: Claude + Janus ASCII art on startup 🔮
73
+ - **Voice Paste**: Speak on your Mac → text appears in remote terminal
74
+ - **Approval Overlay**: Claude permission requests show on your local Mac overlay
75
+ - **Zero latency feel**: WebSocket connection, no polling
76
+ - **Background paste**: No window switching - text appears directly in terminal
77
+ - **Multi-session support**: Run multiple `claude-janus` sessions on different servers
78
+ - **Auto-reconnect**: Handles connection drops gracefully
79
+
80
+ ## Troubleshooting
81
+
82
+ ### "Bridge connection failed"
83
+ - Ensure Janus Electron is running on your local Mac
84
+ - Verify SSH port forwarding is configured
85
+ - Check that port 9473 isn't blocked
86
+
87
+ ### "Could not find 'claude' binary"
88
+ - Install Claude CLI: `npm install -g @anthropic-ai/claude-cli`
89
+ - Or ensure it's in your PATH
90
+
91
+ ## License
92
+
93
+ MIT
94
+
95
+ ## Author
96
+
97
+ He Who Seeks
@@ -0,0 +1,18 @@
1
+ """
2
+ Janus Remote - Voice-to-text paste bridge for Claude CLI on remote SSH sessions
3
+
4
+ Usage:
5
+ pip install janus-remote
6
+ claude-janus # Start Claude with voice paste support
7
+
8
+ Requires:
9
+ - Janus Electron app running on your local Mac
10
+ - SSH port forwarding: RemoteForward 9473 localhost:9473
11
+ """
12
+
13
+ __version__ = "0.2.0"
14
+ __author__ = "He Who Seeks"
15
+
16
+ from .pty_capture import main as run_pty_capture
17
+
18
+ __all__ = ["run_pty_capture", "__version__"]
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI entry point for janus-remote
4
+
5
+ Usage:
6
+ claude-janus # Start new Claude session with voice paste
7
+ claude-janus --resume # Resume previous session
8
+ claude-janus -r # Short form resume
9
+ """
10
+
11
+ import sys
12
+ import os
13
+ import shutil
14
+
15
+
16
+ def print_banner(is_resume=False):
17
+ """Print the sexy Janus terminal banner"""
18
+ print()
19
+ print(" \033[1;38;5;141m█▀▀ █ ▄▀█ █ █ █▀▄ █▀▀\033[0m \033[38;5;208m+\033[0m \033[1;38;5;208m▀█ ▄▀█ █▄ █ █ █ █▀▀\033[0m 🔮")
20
+ print(" \033[1;38;5;141m█▄▄ █▄▄ █▀█ █▄█ █▄▀ ██▄\033[0m \033[1;38;5;208m█▄ █▀█ █ ▀█ █▄█ ▄██\033[0m")
21
+
22
+ if is_resume:
23
+ print(" \033[38;5;245m────────────────────────────────────────────\033[0m")
24
+ print(" \033[38;5;141m⏪ Resume Session\033[0m")
25
+
26
+ print()
27
+
28
+
29
+ def get_session_title():
30
+ """Ask user for optional session title"""
31
+ print(" \033[38;5;245mSession title (Enter to skip): \033[0m", end='', flush=True)
32
+ try:
33
+ title = input().strip()
34
+ return title if title else None
35
+ except (EOFError, KeyboardInterrupt):
36
+ return None
37
+
38
+
39
+ def find_claude():
40
+ """Find the claude binary location"""
41
+ # Check PATH first
42
+ claude_path = shutil.which('claude')
43
+ if claude_path:
44
+ return claude_path
45
+
46
+ # Common locations
47
+ common_paths = [
48
+ '/usr/local/bin/claude',
49
+ '/opt/homebrew/bin/claude',
50
+ os.path.expanduser('~/.local/bin/claude'),
51
+ os.path.expanduser('~/bin/claude'),
52
+ '/usr/bin/claude',
53
+ os.path.expanduser('~/.npm-global/bin/claude'),
54
+ ]
55
+
56
+ for path in common_paths:
57
+ if os.path.isfile(path) and os.access(path, os.X_OK):
58
+ return path
59
+
60
+ return None
61
+
62
+
63
+ def main():
64
+ """Main entry point"""
65
+ # Parse arguments
66
+ args = sys.argv[1:]
67
+ is_resume = False
68
+ ssh_host_alias = None
69
+ claude_args = []
70
+
71
+ i = 0
72
+ while i < len(args):
73
+ arg = args[i]
74
+ if arg in ('--resume', '-r', 'resume'):
75
+ is_resume = True
76
+ claude_args.append('--resume')
77
+ elif arg == '--host':
78
+ if i + 1 < len(args):
79
+ ssh_host_alias = args[i + 1]
80
+ i += 1
81
+ else:
82
+ print("\033[31mError: --host requires a value\033[0m", file=sys.stderr)
83
+ sys.exit(1)
84
+ elif arg.startswith('--host='):
85
+ ssh_host_alias = arg[7:]
86
+ else:
87
+ claude_args.append(arg)
88
+ i += 1
89
+
90
+ # Print sexy banner
91
+ print_banner(is_resume)
92
+
93
+ # Set SSH host alias for bridge matching
94
+ # This should match VSCode's [SSH: xxx] in window title
95
+ if ssh_host_alias:
96
+ os.environ['JANUS_SSH_HOST'] = ssh_host_alias
97
+ print(f" \033[38;5;245mSSH host alias: \033[38;5;208m{ssh_host_alias}\033[0m")
98
+
99
+ # Get optional session title
100
+ title = get_session_title()
101
+ if title:
102
+ os.environ['JANUS_TITLE'] = title
103
+ # Set terminal title
104
+ print(f"\033]0;{title}\007", end='', flush=True)
105
+ print(f" \033[38;5;245mSession: \033[38;5;141m{title}\033[0m")
106
+
107
+ print()
108
+
109
+ args = claude_args
110
+
111
+ # Find claude
112
+ claude_path = find_claude()
113
+
114
+ if not claude_path:
115
+ print("\033[31mError: Could not find 'claude' binary.\033[0m", file=sys.stderr)
116
+ print("Please ensure Claude CLI is installed and in your PATH.", file=sys.stderr)
117
+ print("Install: npm install -g @anthropic-ai/claude-cli", file=sys.stderr)
118
+ sys.exit(1)
119
+
120
+ # Import and run the PTY capture
121
+ from .pty_capture import run_claude_session
122
+
123
+ try:
124
+ run_claude_session(claude_path, args)
125
+ except KeyboardInterrupt:
126
+ print("\n\033[38;5;245mSession interrupted.\033[0m")
127
+ sys.exit(0)
128
+ except Exception as e:
129
+ print(f"\033[31mError: {e}\033[0m", file=sys.stderr)
130
+ sys.exit(1)
131
+
132
+
133
+ if __name__ == '__main__':
134
+ main()
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PTY Capture for Janus Remote
4
+
5
+ Wraps Claude CLI in a PTY and connects to local Janus via WebSocket
6
+ for voice-to-text paste and approval overlay support over SSH.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import pty
12
+ import select
13
+ import termios
14
+ import tty
15
+ from datetime import datetime
16
+ import fcntl
17
+ import time
18
+ import json
19
+ import re
20
+ import threading
21
+ import socket
22
+
23
+ # WebSocket bridge port (must match Janus Electron)
24
+ JANUS_BRIDGE_PORT = 9473
25
+
26
+ # Regex to strip title escape sequences
27
+ TITLE_ESCAPE_PATTERN = re.compile(rb'\x1b\][012];[^\x07\x1b]*(?:\x07|\x1b\\)')
28
+
29
+ # Approval detection patterns - when Claude asks for permission
30
+ APPROVAL_PATTERNS = [
31
+ r'❯\s*1\.\s*Yes',
32
+ r'1\.\s*Yes\s*$',
33
+ r'2\.\s*Yes,?\s*allow all',
34
+ r'3\.\s*No,?\s*and tell',
35
+ r'allow all.*during this session',
36
+ r'tell Claude what to do differently',
37
+ r'Allow\s+(this\s+)?(tool|action|command|operation)',
38
+ r'Do you want to (allow|run|execute|proceed)',
39
+ r'Press Enter to (allow|approve|continue|proceed)',
40
+ r'\[y/n\]',
41
+ r'\[Y/n\]',
42
+ r'\[yes/no\]',
43
+ r'Allow\?',
44
+ r'Approve\?',
45
+ r'Run\s+(this\s+)?(command|bash|script)',
46
+ r'Execute\s+(this\s+)?(command|bash|script)',
47
+ r'(Write|Create|Delete|Modify)\s+(to\s+)?file',
48
+ r'Allow (writing|reading|creating|deleting)',
49
+ r'(y/n/a)',
50
+ r'\(y\)es.*\(n\)o',
51
+ ]
52
+
53
+ COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in APPROVAL_PATTERNS]
54
+
55
+
56
+ def get_janus_title():
57
+ """Get session title from environment"""
58
+ return os.environ.get('JANUS_TITLE', '')
59
+
60
+
61
+ def remove_ansi(text):
62
+ """Remove ANSI escape codes from text"""
63
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
64
+ return ansi_escape.sub('', text)
65
+
66
+
67
+ class RemotePasteClient:
68
+ """WebSocket client for bidirectional communication with local Janus"""
69
+
70
+ def __init__(self, master_fd, port=JANUS_BRIDGE_PORT):
71
+ self.master_fd = master_fd
72
+ self.port = port
73
+ self.ws = None
74
+ self.running = True
75
+ self.connected = False
76
+ self.client_thread = None
77
+ self.session_id = f"pty-{os.getpid()}-{int(time.time())}"
78
+ self.pending_approval = None
79
+ self.approval_id = 0
80
+
81
+ def start(self):
82
+ """Start the WebSocket client in a background thread"""
83
+ self.client_thread = threading.Thread(target=self._run_client, daemon=True)
84
+ self.client_thread.start()
85
+
86
+ def _get_public_ip(self):
87
+ """Get the server's public IP address for SSH config matching"""
88
+ try:
89
+ import urllib.request
90
+ # Try multiple services in case one is down
91
+ services = [
92
+ 'https://api.ipify.org',
93
+ 'https://ifconfig.me/ip',
94
+ 'https://icanhazip.com',
95
+ ]
96
+ for url in services:
97
+ try:
98
+ with urllib.request.urlopen(url, timeout=3) as response:
99
+ ip = response.read().decode('utf-8').strip()
100
+ if ip:
101
+ return ip
102
+ except:
103
+ continue
104
+ except:
105
+ pass
106
+ return ''
107
+
108
+ def _run_client(self):
109
+ """Main client loop - connect and listen for messages"""
110
+ try:
111
+ import websocket
112
+ except ImportError:
113
+ print("\033[38;5;208m[janus-remote]\033[0m websocket-client not installed. Run: pip install websocket-client", file=sys.stderr)
114
+ return
115
+
116
+ while self.running:
117
+ try:
118
+ ws_url = f"ws://localhost:{self.port}"
119
+ print(f"\033[38;5;208m[janus-remote]\033[0m Connecting to Janus bridge...", file=sys.stderr)
120
+
121
+ self.ws = websocket.create_connection(ws_url, timeout=5)
122
+ self.connected = True
123
+ print(f"\033[38;5;82m[janus-remote]\033[0m \033[1mConnected to Janus bridge!\033[0m 🔮", file=sys.stderr)
124
+
125
+ # Register this session
126
+ my_title = get_janus_title()
127
+ system_hostname = socket.gethostname()
128
+
129
+ # Get public IP for automatic SSH config matching
130
+ public_ip = self._get_public_ip()
131
+
132
+ register_msg = json.dumps({
133
+ 'type': 'register',
134
+ 'sessionId': self.session_id,
135
+ 'title': my_title,
136
+ 'hostname': system_hostname,
137
+ 'publicIP': public_ip,
138
+ 'capabilities': ['paste', 'approval']
139
+ })
140
+ self.ws.send(register_msg)
141
+ if public_ip:
142
+ print(f"\033[38;5;245m[janus-remote]\033[0m Public IP: {public_ip}", file=sys.stderr)
143
+
144
+ # Listen for messages
145
+ while self.running and self.connected:
146
+ try:
147
+ self.ws.settimeout(1.0)
148
+ message = self.ws.recv()
149
+ if message:
150
+ self._handle_message(message)
151
+ except websocket.WebSocketTimeoutException:
152
+ try:
153
+ self.ws.send(json.dumps({'type': 'ping'}))
154
+ except:
155
+ break
156
+ except websocket.WebSocketConnectionClosedException:
157
+ print(f"\033[38;5;208m[janus-remote]\033[0m Connection closed", file=sys.stderr)
158
+ break
159
+ except Exception:
160
+ break
161
+
162
+ except Exception as e:
163
+ if self.running:
164
+ pass # Silently retry
165
+ self.connected = False
166
+
167
+ if self.running:
168
+ time.sleep(5)
169
+
170
+ def _handle_message(self, message):
171
+ """Handle incoming WebSocket message"""
172
+ try:
173
+ msg = json.loads(message)
174
+ msg_type = msg.get('type')
175
+
176
+ if msg_type == 'paste':
177
+ text = msg.get('text', '')
178
+ if text:
179
+ print(f"\033[38;5;82m[janus-remote]\033[0m Voice paste received", file=sys.stderr)
180
+ self._inject_text(text)
181
+
182
+ elif msg_type == 'registered':
183
+ pass # Already printed connection message
184
+
185
+ elif msg_type == 'approval_response':
186
+ # Response from local Janus approval overlay
187
+ action = msg.get('action', 'deny')
188
+ self._inject_approval_response(action)
189
+
190
+ except json.JSONDecodeError:
191
+ pass
192
+
193
+ def _inject_text(self, text):
194
+ """Inject text directly into the PTY"""
195
+ try:
196
+ encoded = text.encode('utf-8')
197
+ chunk_size = 256
198
+ for i in range(0, len(encoded), chunk_size):
199
+ chunk = encoded[i:i + chunk_size]
200
+ os.write(self.master_fd, chunk)
201
+ if len(encoded) > chunk_size:
202
+ time.sleep(0.02)
203
+
204
+ time.sleep(0.15)
205
+ os.write(self.master_fd, b'\r')
206
+ except OSError as e:
207
+ print(f"\033[31m[janus-remote]\033[0m Paste error: {e}", file=sys.stderr)
208
+
209
+ def _inject_approval_response(self, action):
210
+ """Inject approval response keystroke to Claude"""
211
+ try:
212
+ if action == 'approve':
213
+ os.write(self.master_fd, b'1')
214
+ time.sleep(0.05)
215
+ os.write(self.master_fd, b'\r')
216
+ print(f"\033[38;5;82m[janus-remote]\033[0m ✓ Approved", file=sys.stderr)
217
+ elif action == 'approve_all':
218
+ os.write(self.master_fd, b'2')
219
+ time.sleep(0.05)
220
+ os.write(self.master_fd, b'\r')
221
+ print(f"\033[38;5;82m[janus-remote]\033[0m ✓ Approved all", file=sys.stderr)
222
+ elif action == 'deny':
223
+ os.write(self.master_fd, b'\x1b') # Escape to cancel
224
+ print(f"\033[38;5;208m[janus-remote]\033[0m ✗ Denied", file=sys.stderr)
225
+ elif action.startswith('deny:'):
226
+ feedback = action[5:]
227
+ os.write(self.master_fd, b'3')
228
+ time.sleep(0.1)
229
+ os.write(self.master_fd, b'\r')
230
+ time.sleep(0.2)
231
+ os.write(self.master_fd, feedback.encode('utf-8'))
232
+ time.sleep(0.05)
233
+ os.write(self.master_fd, b'\r')
234
+ print(f"\033[38;5;208m[janus-remote]\033[0m ✗ Denied with feedback", file=sys.stderr)
235
+ else:
236
+ os.write(self.master_fd, b'\x1b')
237
+
238
+ self.pending_approval = None
239
+
240
+ except OSError as e:
241
+ print(f"\033[31m[janus-remote]\033[0m Approval inject error: {e}", file=sys.stderr)
242
+
243
+ def send_approval_request(self, tool_name, context):
244
+ """Send approval request to local Janus for overlay display"""
245
+ if not self.connected or not self.ws:
246
+ return
247
+
248
+ self.approval_id += 1
249
+ self.pending_approval = {
250
+ 'id': self.approval_id,
251
+ 'timestamp': datetime.now().isoformat(),
252
+ 'tool': tool_name,
253
+ 'context': context
254
+ }
255
+
256
+ try:
257
+ self.ws.send(json.dumps({
258
+ 'type': 'approval_request',
259
+ 'sessionId': self.session_id,
260
+ 'approvalId': self.approval_id,
261
+ 'tool': tool_name,
262
+ 'context': context,
263
+ 'hostname': socket.gethostname()
264
+ }))
265
+ print(f"\033[38;5;141m[janus-remote]\033[0m 🔔 Approval request sent → {tool_name}", file=sys.stderr)
266
+ except Exception:
267
+ pass
268
+
269
+ def clear_pending_approval(self):
270
+ """Clear pending approval (user handled it manually)"""
271
+ self.pending_approval = None
272
+
273
+ def stop(self):
274
+ """Stop the client"""
275
+ self.running = False
276
+ self.connected = False
277
+ if self.ws:
278
+ try:
279
+ self.ws.close()
280
+ except:
281
+ pass
282
+
283
+
284
+ class ApprovalDetector:
285
+ """Detects approval requests in Claude output"""
286
+
287
+ def __init__(self, remote_client):
288
+ self.remote_client = remote_client
289
+ self.recent_lines = []
290
+ self.last_detection_time = 0
291
+ self.detection_cooldown = 0.5
292
+
293
+ def add_line(self, line):
294
+ """Add a line and check for approval patterns"""
295
+ cleaned = remove_ansi(line).strip()
296
+ if not cleaned:
297
+ return
298
+
299
+ self.recent_lines.append(cleaned)
300
+ if len(self.recent_lines) > 50:
301
+ self.recent_lines = self.recent_lines[-30:]
302
+
303
+ # Check for completion markers - clear pending
304
+ if self.remote_client.pending_approval:
305
+ if '✓' in cleaned or '✗' in cleaned:
306
+ self.remote_client.clear_pending_approval()
307
+ return
308
+
309
+ # Check for approval request
310
+ if self._is_approval_request(cleaned):
311
+ current_time = time.time()
312
+ if (current_time - self.last_detection_time) >= self.detection_cooldown:
313
+ if not self.remote_client.pending_approval:
314
+ self.last_detection_time = current_time
315
+ tool_name, context = self._extract_tool_info()
316
+ self.remote_client.send_approval_request(tool_name, context)
317
+
318
+ def _is_approval_request(self, line):
319
+ """Check if line matches approval patterns"""
320
+ for pattern in COMPILED_PATTERNS:
321
+ if pattern.search(line):
322
+ return True
323
+ return False
324
+
325
+ def _extract_tool_info(self):
326
+ """Extract tool name and context from recent lines"""
327
+ tool_name = 'Unknown'
328
+ context = self.recent_lines[-5:] if len(self.recent_lines) >= 5 else self.recent_lines
329
+
330
+ for line in reversed(self.recent_lines):
331
+ if '⏺' in line:
332
+ if 'Bash' in line:
333
+ tool_name = 'Bash'
334
+ elif 'Edit' in line:
335
+ tool_name = 'Edit'
336
+ elif 'Write' in line:
337
+ tool_name = 'Write'
338
+ elif 'Read' in line:
339
+ tool_name = 'Read'
340
+ elif 'Glob' in line:
341
+ tool_name = 'Glob'
342
+ elif 'Grep' in line:
343
+ tool_name = 'Grep'
344
+ elif 'Task' in line:
345
+ tool_name = 'Task'
346
+ break
347
+
348
+ return tool_name, context
349
+
350
+
351
+ def run_claude_session(claude_path, args):
352
+ """Run Claude CLI wrapped in PTY with Janus voice paste support"""
353
+
354
+ old_tty = termios.tcgetattr(sys.stdin)
355
+
356
+ try:
357
+ master_fd, slave_fd = pty.openpty()
358
+ pid = os.fork()
359
+
360
+ if pid == 0: # Child process
361
+ os.close(master_fd)
362
+ os.setsid()
363
+ os.dup2(slave_fd, 0)
364
+ os.dup2(slave_fd, 1)
365
+ os.dup2(slave_fd, 2)
366
+ os.execv(claude_path, [claude_path] + args)
367
+
368
+ else: # Parent process
369
+ os.close(slave_fd)
370
+ tty.setraw(sys.stdin.fileno())
371
+
372
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
373
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
374
+
375
+ # Initialize Remote Client and Approval Detector
376
+ remote_client = RemotePasteClient(master_fd)
377
+ remote_client.start()
378
+
379
+ approval_detector = ApprovalDetector(remote_client)
380
+
381
+ line_buffer = b''
382
+
383
+ while True:
384
+ rfds, _, _ = select.select([sys.stdin, master_fd], [], [], 0.01)
385
+
386
+ pid_status = os.waitpid(pid, os.WNOHANG)
387
+ if pid_status[0] != 0:
388
+ break
389
+
390
+ if sys.stdin in rfds:
391
+ try:
392
+ data = os.read(sys.stdin.fileno(), 1024)
393
+ if data:
394
+ # If user manually handles approval, clear pending
395
+ if remote_client.pending_approval:
396
+ if data in (b'1', b'2', b'3', b'\r', b'\n', b'\x1b'):
397
+ remote_client.clear_pending_approval()
398
+ os.write(master_fd, data)
399
+ except OSError:
400
+ pass
401
+
402
+ if master_fd in rfds:
403
+ try:
404
+ data = os.read(master_fd, 4096)
405
+ if data:
406
+ data = TITLE_ESCAPE_PATTERN.sub(b'', data)
407
+ os.write(sys.stdout.fileno(), data)
408
+
409
+ # Process lines for approval detection
410
+ line_buffer += data
411
+ while b'\n' in line_buffer or b'\r' in line_buffer:
412
+ nl_pos = line_buffer.find(b'\n')
413
+ cr_pos = line_buffer.find(b'\r')
414
+
415
+ if nl_pos == -1:
416
+ pos = cr_pos
417
+ elif cr_pos == -1:
418
+ pos = nl_pos
419
+ else:
420
+ pos = min(nl_pos, cr_pos)
421
+
422
+ if pos == -1:
423
+ break
424
+
425
+ line = line_buffer[:pos]
426
+ line_buffer = line_buffer[pos+1:]
427
+
428
+ text = line.decode('utf-8', errors='ignore')
429
+ approval_detector.add_line(text)
430
+
431
+ except OSError:
432
+ pass
433
+
434
+ remote_client.stop()
435
+ _, exit_status = os.waitpid(pid, 0)
436
+ exit_code = os.WEXITSTATUS(exit_status) if os.WIFEXITED(exit_status) else 1
437
+
438
+ finally:
439
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
440
+
441
+ print()
442
+ print("\033[38;5;245mSession ended.\033[0m")
443
+ sys.exit(exit_code if 'exit_code' in locals() else 0)
444
+
445
+
446
+ def main():
447
+ """Standalone entry point"""
448
+ from .cli import main as cli_main
449
+ cli_main()
450
+
451
+
452
+ if __name__ == '__main__':
453
+ main()
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: janus-remote
3
+ Version: 0.4.0
4
+ Summary: Voice-to-text paste bridge for Claude CLI on remote SSH sessions
5
+ Author: He Who Seeks
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/anthropics/janus
8
+ Project-URL: Repository, https://github.com/anthropics/janus
9
+ Keywords: claude,voice,ssh,remote,paste,janus
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: websocket-client>=1.0.0
26
+ Dynamic: license-file
27
+
28
+ # janus-remote
29
+
30
+ Voice-to-text paste bridge for Claude CLI on remote SSH sessions.
31
+
32
+ Use your voice to interact with Claude CLI running on remote servers, with transcriptions pasted directly into the terminal - no window switching needed!
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install janus-remote
38
+ ```
39
+
40
+ ## Requirements
41
+
42
+ 1. **Local Mac**: Janus Electron app running (provides voice recognition + WebSocket server)
43
+ 2. **SSH Config**: Port forwarding enabled (one-time setup)
44
+
45
+ ## SSH Setup (One-Time)
46
+
47
+ Add this to your `~/.ssh/config` on your **local Mac**:
48
+
49
+ ```
50
+ Host *
51
+ RemoteForward 9473 localhost:9473
52
+ ```
53
+
54
+ Or for specific hosts:
55
+
56
+ ```
57
+ Host myserver
58
+ HostName myserver.example.com
59
+ RemoteForward 9473 localhost:9473
60
+ ```
61
+
62
+ This forwards the Janus WebSocket bridge (port 9473) to the remote server.
63
+
64
+ ## Usage
65
+
66
+ On your remote server via SSH:
67
+
68
+ ```bash
69
+ # Start a new Claude session with voice paste support
70
+ claude-janus
71
+
72
+ # Resume a previous session
73
+ claude-janus --resume
74
+ claude-janus -r
75
+ ```
76
+
77
+ ## How It Works
78
+
79
+ ```
80
+ LOCAL MAC REMOTE SERVER (via SSH)
81
+ ┌─────────────────────┐ ┌─────────────────────┐
82
+ │ Janus Electron │ │ claude-janus │
83
+ │ (Voice Recognition) │ │ (This package) │
84
+ │ │ │ │ │ │
85
+ │ ▼ │ │ │ │
86
+ │ WebSocket :9473 ────┼─────────────┼───────► │ │
87
+ │ │ SSH Tunnel │ ▼ │
88
+ │ │ │ Inject into PTY │
89
+ └─────────────────────┘ └─────────────────────┘
90
+ ```
91
+
92
+ 1. Speak into your Mac's microphone
93
+ 2. Janus transcribes and sends via WebSocket
94
+ 3. SSH tunnel forwards to remote server
95
+ 4. `claude-janus` receives and injects text directly into Claude CLI
96
+
97
+ ## Features
98
+
99
+ - **Sexy Terminal Banner**: Claude + Janus ASCII art on startup 🔮
100
+ - **Voice Paste**: Speak on your Mac → text appears in remote terminal
101
+ - **Approval Overlay**: Claude permission requests show on your local Mac overlay
102
+ - **Zero latency feel**: WebSocket connection, no polling
103
+ - **Background paste**: No window switching - text appears directly in terminal
104
+ - **Multi-session support**: Run multiple `claude-janus` sessions on different servers
105
+ - **Auto-reconnect**: Handles connection drops gracefully
106
+
107
+ ## Troubleshooting
108
+
109
+ ### "Bridge connection failed"
110
+ - Ensure Janus Electron is running on your local Mac
111
+ - Verify SSH port forwarding is configured
112
+ - Check that port 9473 isn't blocked
113
+
114
+ ### "Could not find 'claude' binary"
115
+ - Install Claude CLI: `npm install -g @anthropic-ai/claude-cli`
116
+ - Or ensure it's in your PATH
117
+
118
+ ## License
119
+
120
+ MIT
121
+
122
+ ## Author
123
+
124
+ He Who Seeks
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ janus_remote/__init__.py
5
+ janus_remote/cli.py
6
+ janus_remote/pty_capture.py
7
+ janus_remote.egg-info/PKG-INFO
8
+ janus_remote.egg-info/SOURCES.txt
9
+ janus_remote.egg-info/dependency_links.txt
10
+ janus_remote.egg-info/entry_points.txt
11
+ janus_remote.egg-info/requires.txt
12
+ janus_remote.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ claude-janus = janus_remote.cli:main
3
+ janus-remote = janus_remote.cli:main
@@ -0,0 +1 @@
1
+ websocket-client>=1.0.0
@@ -0,0 +1 @@
1
+ janus_remote
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "janus-remote"
7
+ version = "0.4.0"
8
+ description = "Voice-to-text paste bridge for Claude CLI on remote SSH sessions"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "He Who Seeks"}
13
+ ]
14
+ keywords = ["claude", "voice", "ssh", "remote", "paste", "janus"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Operating System :: MacOS",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ ]
29
+ requires-python = ">=3.8"
30
+ dependencies = [
31
+ "websocket-client>=1.0.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/anthropics/janus"
36
+ Repository = "https://github.com/anthropics/janus"
37
+
38
+ [project.scripts]
39
+ claude-janus = "janus_remote.cli:main"
40
+ janus-remote = "janus_remote.cli:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["."]
44
+ include = ["janus_remote*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+