claude-watch-relay 0.1.0
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/README.md +62 -0
- package/package.json +41 -0
- package/src/index.js +468 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# claude-watch-relay
|
|
2
|
+
|
|
3
|
+
Mac relay server for **Claude Watch Remote** — bridges Claude Code CLI prompts to your iPhone and Apple Watch via WebSocket.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Claude Code (PTY) ←→ claude-watch-relay (WebSocket + Bonjour) ←→ iPhone ←→ Apple Watch
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The relay wraps `claude` in a PTY, detects interactive prompts (permissions, file access, y/n), and broadcasts them over WebSocket to the Claude Watch Remote iOS app.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g claude-watch-relay
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> Requires macOS and Node.js 18+. Xcode Command Line Tools needed for native module build (`xcode-select --install`).
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Instead of running `claude` directly:
|
|
25
|
+
claude-watch-relay
|
|
26
|
+
|
|
27
|
+
# Pass arguments to Claude Code:
|
|
28
|
+
claude-watch-relay --help
|
|
29
|
+
claude-watch-relay "fix the bug in auth.ts"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On startup, the relay prints a **6-digit pairing code**. Enter this code in the Claude Watch Remote iPhone app to pair.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
| Environment Variable | Default | Description |
|
|
37
|
+
|---------------------|---------|-------------|
|
|
38
|
+
| `RELAY_PORT` | `19876` | WebSocket server port |
|
|
39
|
+
|
|
40
|
+
## How the iPhone/Watch finds your Mac
|
|
41
|
+
|
|
42
|
+
The relay advertises itself via **Bonjour** (mDNS) as `_claude-watch._tcp`. The iPhone app auto-discovers Macs on the local network — no manual IP entry needed.
|
|
43
|
+
|
|
44
|
+
## Protocol
|
|
45
|
+
|
|
46
|
+
WebSocket JSON messages:
|
|
47
|
+
|
|
48
|
+
| Direction | Type | Fields |
|
|
49
|
+
|-----------|------|--------|
|
|
50
|
+
| Client → Relay | `pair` | `code` |
|
|
51
|
+
| Relay → Client | `pair_result` | `success`, `message` |
|
|
52
|
+
| Relay → Client | `prompt` | `prompt: { id, type, text, detail, options, timestamp }` |
|
|
53
|
+
| Client → Relay | `response` | `promptId`, `answer` |
|
|
54
|
+
| Client → Relay | `voice_command` | `text` |
|
|
55
|
+
| Relay → Client | `voice_ack` | `text`, `timestamp` |
|
|
56
|
+
| Relay → Client | `prompt_resolved` | `promptId`, `answer` |
|
|
57
|
+
| Relay → Client | `terminal_output` | `data` |
|
|
58
|
+
| Relay → Client | `terminal_exit` | `exitCode` |
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-watch-relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mac relay server for Claude Watch Remote — bridges Claude Code CLI prompts to iPhone/Apple Watch",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-watch-relay": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"test": "node test/test-detector.mjs && node test/test-websocket.mjs",
|
|
13
|
+
"postinstall": "npx node-gyp rebuild --directory=node_modules/node-pty 2>/dev/null || true"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"apple-watch",
|
|
19
|
+
"relay",
|
|
20
|
+
"remote",
|
|
21
|
+
"pty",
|
|
22
|
+
"websocket",
|
|
23
|
+
"bonjour"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": ""
|
|
29
|
+
},
|
|
30
|
+
"homepage": "",
|
|
31
|
+
"os": ["darwin"],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"node-pty": "^1.0.0",
|
|
34
|
+
"ws": "^8.16.0",
|
|
35
|
+
"bonjour-service": "^1.2.1"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT"
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node-pty";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import { Bonjour } from "bonjour-service";
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
|
|
9
|
+
// ─── Prompt Detector ───────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
class PromptDetector {
|
|
12
|
+
constructor() {
|
|
13
|
+
// Patterns that Claude Code uses for interactive prompts
|
|
14
|
+
this.patterns = [
|
|
15
|
+
// Yes/No permission prompts
|
|
16
|
+
{
|
|
17
|
+
type: "permission",
|
|
18
|
+
regex:
|
|
19
|
+
/Do you want to (proceed|continue|allow|accept|run|execute|install)\?/i,
|
|
20
|
+
options: ["Yes", "No"],
|
|
21
|
+
},
|
|
22
|
+
// File access prompts
|
|
23
|
+
{
|
|
24
|
+
type: "file_access",
|
|
25
|
+
regex: /Allow (?:read|write|access) to (.+)\?/i,
|
|
26
|
+
options: ["Allow", "Deny"],
|
|
27
|
+
},
|
|
28
|
+
// Tool use approval
|
|
29
|
+
{
|
|
30
|
+
type: "tool_approval",
|
|
31
|
+
regex:
|
|
32
|
+
/(?:May I|Can I|Allow me to|I'd like to) (?:run|execute|use|call) (.+?)[\?\n]/i,
|
|
33
|
+
options: ["Yes", "No"],
|
|
34
|
+
},
|
|
35
|
+
// Generic yes/no
|
|
36
|
+
{
|
|
37
|
+
type: "yes_no",
|
|
38
|
+
regex: /\(y\/n\)/i,
|
|
39
|
+
options: ["y", "n"],
|
|
40
|
+
},
|
|
41
|
+
// Bash tool approval (Claude Code specific)
|
|
42
|
+
{
|
|
43
|
+
type: "bash_approval",
|
|
44
|
+
regex: /❯ (.+)\n.*(?:Allow|Run|Execute)\?/s,
|
|
45
|
+
options: ["Allow", "Deny"],
|
|
46
|
+
},
|
|
47
|
+
// Edit file approval
|
|
48
|
+
{
|
|
49
|
+
type: "edit_approval",
|
|
50
|
+
regex: /(?:Edit|Write|Create) (?:file )?(.+?)[\?\n]/i,
|
|
51
|
+
options: ["Allow", "Deny"],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
this.buffer = "";
|
|
56
|
+
this.bufferTimeout = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Feed terminal output and detect prompts.
|
|
61
|
+
* Returns detected prompt or null.
|
|
62
|
+
*/
|
|
63
|
+
feed(data) {
|
|
64
|
+
// Accumulate output in a rolling buffer
|
|
65
|
+
this.buffer += data;
|
|
66
|
+
|
|
67
|
+
// Keep buffer manageable (last 4KB)
|
|
68
|
+
if (this.buffer.length > 4096) {
|
|
69
|
+
this.buffer = this.buffer.slice(-4096);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Strip ANSI escape codes for matching
|
|
73
|
+
const clean = this.stripAnsi(this.buffer);
|
|
74
|
+
|
|
75
|
+
for (const pattern of this.patterns) {
|
|
76
|
+
const match = clean.match(pattern.regex);
|
|
77
|
+
if (match) {
|
|
78
|
+
const prompt = {
|
|
79
|
+
id: randomBytes(8).toString("hex"),
|
|
80
|
+
type: pattern.type,
|
|
81
|
+
text: match[0].trim(),
|
|
82
|
+
detail: match[1] || null,
|
|
83
|
+
options: pattern.options,
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
// Clear the buffer after detection to avoid re-triggering
|
|
87
|
+
this.buffer = "";
|
|
88
|
+
return prompt;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stripAnsi(str) {
|
|
96
|
+
// eslint-disable-next-line no-control-regex
|
|
97
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Terminal Manager ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
class TerminalManager {
|
|
104
|
+
constructor(onData, onExit) {
|
|
105
|
+
this.pty = null;
|
|
106
|
+
this.onData = onData;
|
|
107
|
+
this.onExit = onExit;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Spawn Claude Code in a PTY.
|
|
112
|
+
* Passes through all args after the relay command.
|
|
113
|
+
*/
|
|
114
|
+
start(args = []) {
|
|
115
|
+
// Resolve claude command — it may be an alias, npx, or a real binary
|
|
116
|
+
let claudeCmd;
|
|
117
|
+
let claudeArgs;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Check for a real binary first
|
|
121
|
+
const realPath = execSync("command -v claude 2>/dev/null || true", {
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
shell: "/bin/zsh",
|
|
124
|
+
}).trim();
|
|
125
|
+
|
|
126
|
+
if (realPath && !realPath.includes("alias") && !realPath.includes("not found")) {
|
|
127
|
+
claudeCmd = realPath;
|
|
128
|
+
claudeArgs = args;
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error("not a binary");
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Fall back: launch via npx
|
|
134
|
+
const npxPath = execSync("which npx", {
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
shell: "/bin/zsh",
|
|
137
|
+
}).trim();
|
|
138
|
+
|
|
139
|
+
if (!npxPath) {
|
|
140
|
+
console.error(
|
|
141
|
+
"[relay] Error: Neither 'claude' nor 'npx' found in PATH.\n" +
|
|
142
|
+
" Install Claude Code CLI: npm install -g @anthropic-ai/claude-code"
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
claudeCmd = npxPath;
|
|
148
|
+
claudeArgs = ["@anthropic-ai/claude-code", ...args];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`[relay] Spawning: ${claudeCmd} ${claudeArgs.join(" ")}`);
|
|
152
|
+
|
|
153
|
+
this.pty = spawn(claudeCmd, claudeArgs, {
|
|
154
|
+
name: "xterm-256color",
|
|
155
|
+
cols: process.stdout.columns || 120,
|
|
156
|
+
rows: process.stdout.rows || 40,
|
|
157
|
+
cwd: process.cwd(),
|
|
158
|
+
env: { ...process.env, TERM: "xterm-256color" },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.pty.onData((data) => {
|
|
162
|
+
// Pass through to local terminal
|
|
163
|
+
process.stdout.write(data);
|
|
164
|
+
// Notify callback for prompt detection
|
|
165
|
+
this.onData(data);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.pty.onExit(({ exitCode }) => {
|
|
169
|
+
console.log(`\n[relay] Claude Code exited with code ${exitCode}`);
|
|
170
|
+
this.onExit(exitCode);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Handle terminal resize
|
|
174
|
+
process.stdout.on("resize", () => {
|
|
175
|
+
if (this.pty) {
|
|
176
|
+
this.pty.resize(
|
|
177
|
+
process.stdout.columns || 120,
|
|
178
|
+
process.stdout.rows || 40
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Forward local stdin to PTY
|
|
184
|
+
if (process.stdin.isTTY) {
|
|
185
|
+
process.stdin.setRawMode(true);
|
|
186
|
+
}
|
|
187
|
+
process.stdin.resume();
|
|
188
|
+
process.stdin.on("data", (data) => {
|
|
189
|
+
if (this.pty) {
|
|
190
|
+
this.pty.write(data.toString());
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send a response string to the PTY (simulates user input).
|
|
197
|
+
*/
|
|
198
|
+
sendResponse(text) {
|
|
199
|
+
if (this.pty) {
|
|
200
|
+
this.pty.write(text);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
kill() {
|
|
205
|
+
if (this.pty) {
|
|
206
|
+
this.pty.kill();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Relay Server (WebSocket + Bonjour) ────────────────────────
|
|
212
|
+
|
|
213
|
+
class RelayServer {
|
|
214
|
+
constructor() {
|
|
215
|
+
this.wss = null;
|
|
216
|
+
this.bonjour = null;
|
|
217
|
+
this.clients = new Map(); // ws -> { authenticated, pairCode }
|
|
218
|
+
this.pairCode = this.generatePairCode();
|
|
219
|
+
this.detector = new PromptDetector();
|
|
220
|
+
this.terminal = null;
|
|
221
|
+
this.pendingPrompts = new Map(); // promptId -> prompt
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
generatePairCode() {
|
|
225
|
+
return Math.floor(100000 + Math.random() * 900000).toString();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
start() {
|
|
229
|
+
const port = parseInt(process.env.RELAY_PORT || "19876", 10);
|
|
230
|
+
|
|
231
|
+
// Start WebSocket server
|
|
232
|
+
this.wss = new WebSocketServer({ port });
|
|
233
|
+
console.log(`[relay] WebSocket server listening on port ${port}`);
|
|
234
|
+
console.log(`[relay] Pairing code: ${this.pairCode}`);
|
|
235
|
+
|
|
236
|
+
this.wss.on("connection", (ws) => {
|
|
237
|
+
console.log("[relay] New client connected");
|
|
238
|
+
this.clients.set(ws, { authenticated: false });
|
|
239
|
+
|
|
240
|
+
ws.on("message", (raw) => {
|
|
241
|
+
this.handleMessage(ws, raw);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
ws.on("close", () => {
|
|
245
|
+
console.log("[relay] Client disconnected");
|
|
246
|
+
this.clients.delete(ws);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
ws.on("error", (err) => {
|
|
250
|
+
console.error("[relay] WebSocket error:", err.message);
|
|
251
|
+
this.clients.delete(ws);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Advertise via Bonjour/mDNS
|
|
256
|
+
this.bonjour = new Bonjour();
|
|
257
|
+
this.bonjour.publish({
|
|
258
|
+
name: "Claude Watch Relay",
|
|
259
|
+
type: "claude-watch",
|
|
260
|
+
protocol: "tcp",
|
|
261
|
+
port,
|
|
262
|
+
txt: {
|
|
263
|
+
version: "1",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
console.log("[relay] Bonjour service published: _claude-watch._tcp");
|
|
267
|
+
|
|
268
|
+
// Start Claude Code in PTY
|
|
269
|
+
const claudeArgs = process.argv.slice(2);
|
|
270
|
+
this.terminal = new TerminalManager(
|
|
271
|
+
(data) => this.onTerminalData(data),
|
|
272
|
+
(code) => this.onTerminalExit(code)
|
|
273
|
+
);
|
|
274
|
+
this.terminal.start(claudeArgs);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
handleMessage(ws, raw) {
|
|
278
|
+
let msg;
|
|
279
|
+
try {
|
|
280
|
+
msg = JSON.parse(raw.toString());
|
|
281
|
+
} catch {
|
|
282
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const client = this.clients.get(ws);
|
|
287
|
+
|
|
288
|
+
switch (msg.type) {
|
|
289
|
+
case "pair": {
|
|
290
|
+
if (msg.code === this.pairCode) {
|
|
291
|
+
client.authenticated = true;
|
|
292
|
+
ws.send(
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
type: "pair_result",
|
|
295
|
+
success: true,
|
|
296
|
+
message: "Paired successfully",
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
console.log("[relay] Client authenticated");
|
|
300
|
+
// Send any pending prompts
|
|
301
|
+
for (const prompt of this.pendingPrompts.values()) {
|
|
302
|
+
ws.send(JSON.stringify({ type: "prompt", prompt }));
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
ws.send(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
type: "pair_result",
|
|
308
|
+
success: false,
|
|
309
|
+
message: "Invalid pairing code",
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case "response": {
|
|
317
|
+
if (!client.authenticated) {
|
|
318
|
+
ws.send(
|
|
319
|
+
JSON.stringify({ type: "error", message: "Not authenticated" })
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.handlePromptResponse(msg);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case "voice_command": {
|
|
328
|
+
if (!client.authenticated) {
|
|
329
|
+
ws.send(
|
|
330
|
+
JSON.stringify({ type: "error", message: "Not authenticated" })
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.handleVoiceCommand(msg);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case "ping": {
|
|
339
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
ws.send(
|
|
345
|
+
JSON.stringify({ type: "error", message: `Unknown type: ${msg.type}` })
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
handlePromptResponse(msg) {
|
|
351
|
+
const { promptId, answer } = msg;
|
|
352
|
+
const prompt = this.pendingPrompts.get(promptId);
|
|
353
|
+
if (!prompt) {
|
|
354
|
+
console.log(`[relay] Unknown prompt ID: ${promptId}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
console.log(`[relay] Response for prompt ${promptId}: ${answer}`);
|
|
359
|
+
this.pendingPrompts.delete(promptId);
|
|
360
|
+
|
|
361
|
+
// Send the answer to Claude Code's PTY
|
|
362
|
+
// Map the answer to actual keystrokes
|
|
363
|
+
const keystroke = this.mapAnswerToKeystroke(prompt, answer);
|
|
364
|
+
this.terminal.sendResponse(keystroke);
|
|
365
|
+
|
|
366
|
+
// Notify all authenticated clients that the prompt was answered
|
|
367
|
+
this.broadcast({
|
|
368
|
+
type: "prompt_resolved",
|
|
369
|
+
promptId,
|
|
370
|
+
answer,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
mapAnswerToKeystroke(prompt, answer) {
|
|
375
|
+
const lower = answer.toLowerCase();
|
|
376
|
+
|
|
377
|
+
if (prompt.type === "yes_no") {
|
|
378
|
+
return lower.startsWith("y") ? "y" : "n";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// For most Claude Code prompts, "y" + Enter or "n" + Enter
|
|
382
|
+
if (lower === "yes" || lower === "allow" || lower === "y") {
|
|
383
|
+
return "y\n";
|
|
384
|
+
}
|
|
385
|
+
if (lower === "no" || lower === "deny" || lower === "n") {
|
|
386
|
+
return "n\n";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// For free-form input, send as-is with Enter
|
|
390
|
+
return answer + "\n";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
handleVoiceCommand(msg) {
|
|
394
|
+
const { text } = msg;
|
|
395
|
+
console.log(`[relay] Voice command: ${text}`);
|
|
396
|
+
|
|
397
|
+
// Send voice text directly to Claude Code's PTY as user input
|
|
398
|
+
this.terminal.sendResponse(text + "\n");
|
|
399
|
+
|
|
400
|
+
this.broadcast({
|
|
401
|
+
type: "voice_ack",
|
|
402
|
+
text,
|
|
403
|
+
timestamp: Date.now(),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
onTerminalData(data) {
|
|
408
|
+
const prompt = this.detector.feed(data);
|
|
409
|
+
if (prompt) {
|
|
410
|
+
console.log(`[relay] Detected prompt: ${prompt.type} — ${prompt.text}`);
|
|
411
|
+
this.pendingPrompts.set(prompt.id, prompt);
|
|
412
|
+
|
|
413
|
+
// Send to all authenticated clients
|
|
414
|
+
this.broadcast({ type: "prompt", prompt });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Also send raw terminal output to clients for live view (optional)
|
|
418
|
+
this.broadcast({
|
|
419
|
+
type: "terminal_output",
|
|
420
|
+
data: data.toString(),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
onTerminalExit(code) {
|
|
425
|
+
this.broadcast({
|
|
426
|
+
type: "terminal_exit",
|
|
427
|
+
exitCode: code,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Graceful shutdown
|
|
431
|
+
setTimeout(() => {
|
|
432
|
+
this.shutdown();
|
|
433
|
+
process.exit(code);
|
|
434
|
+
}, 1000);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
broadcast(msg) {
|
|
438
|
+
const json = JSON.stringify(msg);
|
|
439
|
+
for (const [ws, client] of this.clients) {
|
|
440
|
+
if (client.authenticated && ws.readyState === 1) {
|
|
441
|
+
ws.send(json);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
shutdown() {
|
|
447
|
+
console.log("[relay] Shutting down...");
|
|
448
|
+
if (this.terminal) this.terminal.kill();
|
|
449
|
+
if (this.bonjour) this.bonjour.destroy();
|
|
450
|
+
if (this.wss) this.wss.close();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ─── Main ──────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
const relay = new RelayServer();
|
|
457
|
+
|
|
458
|
+
process.on("SIGINT", () => {
|
|
459
|
+
relay.shutdown();
|
|
460
|
+
process.exit(0);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
process.on("SIGTERM", () => {
|
|
464
|
+
relay.shutdown();
|
|
465
|
+
process.exit(0);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
relay.start();
|