@sidevoice/uplink 0.2.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 +29 -0
- package/adapters.mjs +69 -0
- package/cli.mjs +7 -0
- package/connector.mjs +222 -0
- package/harness-claude.mjs +88 -0
- package/mcp.mjs +150 -0
- package/package.json +30 -0
- package/pair.mjs +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Sidevoice uplink — the client side (`@sidevoice/uplink`)
|
|
2
|
+
|
|
3
|
+
The client side. Three entry points behind one bin (`sidevoice`):
|
|
4
|
+
|
|
5
|
+
- `mcp` — the stdio MCP server a harness starts. One per conversation. Exposes
|
|
6
|
+
`voice_connect`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
|
|
7
|
+
operational instructions in its `initialize` result. It never talks to the
|
|
8
|
+
room: it keeps one local connection to the connector for as long as the
|
|
9
|
+
session lives, and the binding it registered dies with that connection.
|
|
10
|
+
- `connector` — one per machine, started by the first façade that needs it and
|
|
11
|
+
gone fifteen seconds after the last binding leaves. Holds the outbound
|
|
12
|
+
WebSocket to the room, re-announces its bindings after a reconnect, keeps a
|
|
13
|
+
durable outbox for speech published while offline, answers the room's
|
|
14
|
+
heartbeat, and delivers one input event at a time per binding through the
|
|
15
|
+
adapter that binding was registered with. A file lock makes it a singleton.
|
|
16
|
+
- `pair` — redeems a pairing code from the room UI for this machine's
|
|
17
|
+
credential (`~/.sidevoice/credentials.json`, mode 0600).
|
|
18
|
+
|
|
19
|
+
Adapters (`adapters.mjs`): `claude-uds` writes the voice envelope as a user
|
|
20
|
+
message to the Claude Code session's inbox socket; `codex-queue` runs
|
|
21
|
+
`codex queue --thread <id>`; `http` posts to any local receiver that accepts the
|
|
22
|
+
room's message shape (for harnesses that have neither).
|
|
23
|
+
|
|
24
|
+
Protocol (newline-free JSON over the WebSocket): `connector.hello` ->
|
|
25
|
+
`connector.welcome`; `binding.register` -> `binding.registered|rejected`;
|
|
26
|
+
`binding.unregister`; `input.deliver` -> `input.ack`; `speech.publish` ->
|
|
27
|
+
`speech.published`; `heartbeat` <-> `heartbeat.ack`. Protocol version 1.
|
|
28
|
+
|
|
29
|
+
Node 22+, no dependencies. Tests: `node --test test/test_connector.mjs`.
|
package/adapters.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** The last mile, one function per harness. Each takes a binding's delivery target and a room event. */
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/** The header the skill expects before the user's literal words. */
|
|
6
|
+
export function envelope(event) {
|
|
7
|
+
const header = { channel: event.channel === 'room-control' ? 'room-control' : 'voice',
|
|
8
|
+
session_id: event.session_id, revision: event.revision, message_id: event.message_id };
|
|
9
|
+
return JSON.stringify(header) + '\n\n' + event.text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Claude Code: the session's own inbox socket, inherited by the façade that registered the binding.
|
|
13
|
+
* The inbox sends no acknowledgement, so this can never report more than what the wire showed:
|
|
14
|
+
* the peer hanging up right after the frames is the one observable sign of a refusal. */
|
|
15
|
+
function deliverClaude(delivery, event) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
const socket = net.createConnection(delivery.socket);
|
|
19
|
+
let settled = false, wrote = 0, replied = '';
|
|
20
|
+
const finish = (error, status, detail) => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true; clearTimeout(timer); socket.destroy();
|
|
23
|
+
if (error) return reject(error);
|
|
24
|
+
resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
|
|
27
|
+
socket.on('error', error => finish(error));
|
|
28
|
+
socket.on('data', chunk => { replied += chunk; });
|
|
29
|
+
socket.on('connect', () => {
|
|
30
|
+
socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
|
|
31
|
+
socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
|
|
32
|
+
wrote = Date.now();
|
|
33
|
+
});
|
|
34
|
+
// A hang-up right after the frames is how a refused auth or a closed inbox looks from here.
|
|
35
|
+
socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Codex: `codex queue` enqueues the next user turn on the local app-server daemon. */
|
|
40
|
+
function deliverCodex(delivery, event) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
|
|
43
|
+
const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
|
|
44
|
+
execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
45
|
+
if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
|
|
46
|
+
resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fallback: an HTTP receiver next to the harness (the slimmed Codex Desktop bridge speaks this). */
|
|
52
|
+
async function deliverHttp(delivery, event) {
|
|
53
|
+
const response = await fetch(delivery.url, {
|
|
54
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
|
|
56
|
+
session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
|
|
57
|
+
signal: AbortSignal.timeout(30_000),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
|
|
60
|
+
return { status: 'accepted', detail: `receiver answered ${response.status}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const adapters = { 'claude-uds': deliverClaude, 'codex-queue': deliverCodex, http: deliverHttp };
|
|
64
|
+
|
|
65
|
+
export function deliver(delivery, event) {
|
|
66
|
+
const adapter = adapters[delivery?.kind];
|
|
67
|
+
if (!adapter) throw new Error(`Unknown delivery kind: ${delivery?.kind}`);
|
|
68
|
+
return adapter(delivery, event);
|
|
69
|
+
}
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** `sidevoice <mcp|pair|connector> …` — one bin, three entry points. */
|
|
3
|
+
const [, , command, ...rest] = process.argv;
|
|
4
|
+
const entries = { mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs' };
|
|
5
|
+
if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector> [args]'); process.exit(2); }
|
|
6
|
+
process.argv.splice(2, 1);
|
|
7
|
+
await import(entries[command]);
|
package/connector.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** One connector per host: an outbound WebSocket to the room, every binding multiplexed over it,
|
|
3
|
+
* and the last mile chosen per binding. Node 22+, no dependencies. Façades talk to it over a
|
|
4
|
+
* local socket; a binding lives exactly as long as the façade connection that registered it. */
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { deliver } from './adapters.mjs';
|
|
11
|
+
|
|
12
|
+
export const PROTOCOL = 1;
|
|
13
|
+
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
14
|
+
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
15
|
+
const lockPath = socketPath + '.lock';
|
|
16
|
+
const outboxPath = path.join(dataDir, 'outbox.json');
|
|
17
|
+
const credentialsPath = process.env.SIDEVOICE_CREDENTIALS || path.join(dataDir, 'credentials.json');
|
|
18
|
+
const idleMs = Number(process.env.SIDEVOICE_CONNECTOR_IDLE_MS || 15_000);
|
|
19
|
+
const hostId = process.env.SIDEVOICE_HOST_ID || os.hostname();
|
|
20
|
+
|
|
21
|
+
function credentials() {
|
|
22
|
+
let saved = {};
|
|
23
|
+
try { saved = JSON.parse(readFileSync(credentialsPath, 'utf8')); } catch {}
|
|
24
|
+
const url = process.env.SIDEVOICE_URL || saved.url;
|
|
25
|
+
const connector_id = process.env.SIDEVOICE_CONNECTOR_ID || saved.connector_id;
|
|
26
|
+
const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
|
|
27
|
+
if (!url || !connector_id || !token) throw new Error(`Not paired: run pair.mjs first (looked in ${credentialsPath})`);
|
|
28
|
+
const parsed = new URL(url);
|
|
29
|
+
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
|
|
30
|
+
if (parsed.protocol !== 'wss:' && !loopback) throw new Error('The room URL must be wss:// unless it is loopback');
|
|
31
|
+
return { url, connector_id, token };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
|
|
35
|
+
function acquireLock() {
|
|
36
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
37
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
38
|
+
try { const fd = openSync(lockPath, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd); return true; }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'EEXIST') throw error;
|
|
41
|
+
let pid = 0; try { pid = Number(readFileSync(lockPath, 'utf8')); } catch {}
|
|
42
|
+
if (pid && alive(pid)) return false; // A live connector holds it: we are redundant.
|
|
43
|
+
try { unlinkSync(lockPath); } catch {} // Stale lock from a dead process.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, owner, chain }
|
|
50
|
+
const registering = new Map(); // client_ref -> { resolve, reject, timer }
|
|
51
|
+
const publishing = new Map(); // event_id -> { resolve, timer }
|
|
52
|
+
const clients = new Set(); // façade IPC connections
|
|
53
|
+
let outbox = []; // speech frames not yet confirmed by the room
|
|
54
|
+
let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
|
|
55
|
+
let creds;
|
|
56
|
+
|
|
57
|
+
function loadOutbox() { try { outbox = JSON.parse(readFileSync(outboxPath, 'utf8')); if (!Array.isArray(outbox)) outbox = []; } catch { outbox = []; } }
|
|
58
|
+
function saveOutbox() {
|
|
59
|
+
const temporary = outboxPath + '.' + process.pid + '.tmp';
|
|
60
|
+
writeFileSync(temporary, JSON.stringify(outbox), { mode: 0o600 }); renameSync(temporary, outboxPath);
|
|
61
|
+
}
|
|
62
|
+
function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
|
|
63
|
+
|
|
64
|
+
function open() {
|
|
65
|
+
if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
66
|
+
const socket = ws = new WebSocket(creds.url);
|
|
67
|
+
socket.addEventListener('open', () => {
|
|
68
|
+
send({ type: 'connector.hello', protocol: PROTOCOL, connector_id: creds.connector_id, token: creds.token, host: hostId });
|
|
69
|
+
});
|
|
70
|
+
socket.addEventListener('message', event => { receive(JSON.parse(String(event.data))).catch(error => send({ type: 'connector.error', error: error.message })); });
|
|
71
|
+
const lost = () => { if (ws === socket) { ws = null; connected = false; } reconnect(); };
|
|
72
|
+
// A refused connection surfaces as 'error' with no 'close', and the dead socket stays
|
|
73
|
+
// CONNECTING forever: forget it, or open() would never make another one.
|
|
74
|
+
socket.addEventListener('close', lost);
|
|
75
|
+
socket.addEventListener('error', lost);
|
|
76
|
+
}
|
|
77
|
+
function reconnect() {
|
|
78
|
+
if (closed || reconnectTimer) return;
|
|
79
|
+
const delay = Math.min(10_000, 250 * 2 ** Math.min(reconnectAttempt++, 6));
|
|
80
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, delay);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function receive(frame) {
|
|
84
|
+
switch (frame.type) {
|
|
85
|
+
case 'connector.welcome':
|
|
86
|
+
connected = true; reconnectAttempt = 0; lastError = null;
|
|
87
|
+
for (const binding of bindings.values()) {
|
|
88
|
+
// `local-*` is only a connector-side placeholder while the first
|
|
89
|
+
// registration waits for the room to mint its durable binding id.
|
|
90
|
+
// Sending it back makes the room correctly reject it as foreign.
|
|
91
|
+
const frame = { type: 'binding.register', client_ref: binding.client_ref,
|
|
92
|
+
harness: binding.harness, thread: binding.thread, title: binding.title,
|
|
93
|
+
inbound: binding.inbound, focus: false };
|
|
94
|
+
if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
|
|
95
|
+
send(frame);
|
|
96
|
+
}
|
|
97
|
+
for (const speech of outbox) send(speech);
|
|
98
|
+
return;
|
|
99
|
+
case 'heartbeat': send({ type: 'heartbeat.ack', nonce: frame.nonce }); return;
|
|
100
|
+
case 'binding.registered': {
|
|
101
|
+
const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
|
|
102
|
+
if (binding && binding.binding_id !== frame.binding_id) { bindings.delete(binding.binding_id); binding.binding_id = frame.binding_id; bindings.set(frame.binding_id, binding); }
|
|
103
|
+
registering.get(frame.client_ref)?.resolve(frame); return;
|
|
104
|
+
}
|
|
105
|
+
case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
|
|
106
|
+
case 'speech.published': {
|
|
107
|
+
outbox = outbox.filter(speech => speech.event_id !== frame.event_id); saveOutbox();
|
|
108
|
+
publishing.get(frame.event_id)?.resolve(frame); return;
|
|
109
|
+
}
|
|
110
|
+
case 'input.deliver': {
|
|
111
|
+
const binding = bindings.get(frame.binding_id);
|
|
112
|
+
if (!binding) { send({ type: 'input.ack', event_id: frame.event_id, status: 'unknown_binding' }); return; }
|
|
113
|
+
// One delivery at a time per binding keeps the user's turns in order.
|
|
114
|
+
binding.chain = (binding.chain || Promise.resolve()).then(async () => {
|
|
115
|
+
try {
|
|
116
|
+
const outcome = await deliver(binding.delivery, frame);
|
|
117
|
+
console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
|
|
118
|
+
send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
|
|
121
|
+
send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function snapshot() {
|
|
131
|
+
return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError,
|
|
132
|
+
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery }) => ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind })) };
|
|
133
|
+
}
|
|
134
|
+
function scheduleExit() {
|
|
135
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
136
|
+
idleTimer = setTimeout(() => { if (clients.size === 0 && bindings.size === 0) shutdown(); }, idleMs);
|
|
137
|
+
}
|
|
138
|
+
function shutdown() {
|
|
139
|
+
closed = true; clearTimeout(reconnectTimer); clearTimeout(idleTimer);
|
|
140
|
+
try { ws?.close(); } catch {}
|
|
141
|
+
server.close();
|
|
142
|
+
try { if (Number(readFileSync(lockPath, 'utf8')) === process.pid) { unlinkSync(socketPath); unlinkSync(lockPath); } } catch {}
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function command(client, input) {
|
|
147
|
+
const params = input.params || {};
|
|
148
|
+
switch (input.method) {
|
|
149
|
+
case 'register': {
|
|
150
|
+
const { client_ref, harness, thread, title, delivery, inbound } = params;
|
|
151
|
+
if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
|
|
152
|
+
const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
|
|
153
|
+
if (existing) { existing.owner = client; existing.delivery = delivery; client.bindings.add(existing); return { binding_id: existing.binding_id, thread, connected }; }
|
|
154
|
+
const local_id = 'local-' + randomUUID();
|
|
155
|
+
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, owner: client };
|
|
156
|
+
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open();
|
|
157
|
+
const frame = await new Promise((resolve, reject) => {
|
|
158
|
+
const timer = setTimeout(() => { registering.delete(client_ref); reject(new Error(connected ? 'The room did not confirm the binding' : 'The room is unreachable; retrying in the background')); }, 10_000);
|
|
159
|
+
registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
|
|
160
|
+
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound })) { /* sent on welcome */ }
|
|
161
|
+
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); throw error; });
|
|
162
|
+
return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
|
|
163
|
+
}
|
|
164
|
+
case 'publish': {
|
|
165
|
+
const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
|
|
166
|
+
if (!binding) throw new Error('Unknown binding');
|
|
167
|
+
const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
|
|
168
|
+
session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
|
|
169
|
+
outbox.push(speech); saveOutbox();
|
|
170
|
+
if (!send(speech)) return { status: 'queued', utterance_id: speech.utterance_id };
|
|
171
|
+
const reply = await new Promise(resolve => {
|
|
172
|
+
const timer = setTimeout(() => { publishing.delete(speech.event_id); resolve(null); }, 15_000);
|
|
173
|
+
publishing.set(speech.event_id, { resolve: f => { clearTimeout(timer); publishing.delete(speech.event_id); resolve(f); } });
|
|
174
|
+
});
|
|
175
|
+
if (!reply) return { status: 'queued', utterance_id: speech.utterance_id };
|
|
176
|
+
const { type, event_id, ...result } = reply;
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
case 'unregister': {
|
|
180
|
+
const binding = bindings.get(params.binding_id);
|
|
181
|
+
if (binding) { bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
|
|
182
|
+
scheduleExit(); return snapshot();
|
|
183
|
+
}
|
|
184
|
+
case 'status': return snapshot();
|
|
185
|
+
default: throw new Error('Unknown connector command');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function serve(socket) {
|
|
190
|
+
const client = { socket, bindings: new Set() };
|
|
191
|
+
clients.add(client); clearTimeout(idleTimer);
|
|
192
|
+
let buffer = '';
|
|
193
|
+
socket.on('data', chunk => {
|
|
194
|
+
buffer += chunk;
|
|
195
|
+
if (buffer.length > 1 << 20) { socket.destroy(); return; }
|
|
196
|
+
let index;
|
|
197
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
198
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
let input; try { input = JSON.parse(line); } catch { socket.write(JSON.stringify({ ok: false, error: 'Invalid JSON' }) + '\n'); continue; }
|
|
201
|
+
command(client, input).then(result => socket.write(JSON.stringify({ id: input.id, ok: true, result }) + '\n'))
|
|
202
|
+
.catch(error => socket.write(JSON.stringify({ id: input.id, ok: false, error: error.message }) + '\n'));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
socket.on('error', () => {});
|
|
206
|
+
socket.on('close', () => {
|
|
207
|
+
clients.delete(client);
|
|
208
|
+
// The façade is gone: so is every conversation it spoke for.
|
|
209
|
+
for (const binding of client.bindings) { bindings.delete(binding.binding_id); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
|
|
210
|
+
scheduleExit();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
creds = credentials();
|
|
215
|
+
if (!acquireLock()) process.exit(0);
|
|
216
|
+
loadOutbox();
|
|
217
|
+
try { unlinkSync(socketPath); } catch {}
|
|
218
|
+
const server = net.createServer(serve);
|
|
219
|
+
await new Promise((resolve, reject) => server.once('error', reject).listen(socketPath, resolve));
|
|
220
|
+
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
|
|
221
|
+
scheduleExit();
|
|
222
|
+
open();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** What Claude Code will do with a message we post to a session's inbox, decided before we post it.
|
|
2
|
+
*
|
|
3
|
+
* A session that bypasses permission prompts holds an injected message for its user's approval
|
|
4
|
+
* instead of delivering it, and the inbox sends us no receipt to say so — the write looks
|
|
5
|
+
* identical either way. So the only honest moment to find out is at voice_connect, from the
|
|
6
|
+
* session's own launch flags and settings. Best effort by design: a managed policy layer we
|
|
7
|
+
* cannot read could tighten this further, and the result says so rather than pretending.
|
|
8
|
+
* Documented at https://code.claude.com/docs/en/cross-session-messaging */
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
15
|
+
|
|
16
|
+
function readJson(file) {
|
|
17
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The pid of the session with this id, from Claude Code's own session registry. */
|
|
21
|
+
function sessionPid(sessionId) {
|
|
22
|
+
const registry = path.join(configDir(), 'sessions');
|
|
23
|
+
let entries = [];
|
|
24
|
+
try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
|
|
25
|
+
for (const name of entries) {
|
|
26
|
+
const record = readJson(path.join(registry, name));
|
|
27
|
+
if (record?.sessionId === sessionId) return record.pid ?? null;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The launch arguments of a pid, via ps so this works the same on Linux and macOS. */
|
|
33
|
+
function launchArgs(pid) {
|
|
34
|
+
if (!pid) return '';
|
|
35
|
+
try { return execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 4000 }).trim(); }
|
|
36
|
+
catch { return ''; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function flag(args, name) {
|
|
40
|
+
const match = args.match(new RegExp(`${name}[= ]('[^']*'|"[^"]*"|\\S+)`));
|
|
41
|
+
if (!match) return undefined;
|
|
42
|
+
return match[1].replace(/^['"]|['"]$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `--settings` takes inline JSON or a path to a file; both may carry crossSessionInbound. */
|
|
46
|
+
function settingsFromFlag(args) {
|
|
47
|
+
const value = flag(args, '--settings');
|
|
48
|
+
if (!value) return null;
|
|
49
|
+
if (value.trim().startsWith('{')) { try { return JSON.parse(value); } catch { return null; } }
|
|
50
|
+
return readJson(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BYPASS_MODES = new Set(['bypassPermissions']);
|
|
54
|
+
|
|
55
|
+
/** Will an injected message be delivered to this Claude session, or held for its user? */
|
|
56
|
+
export function inspectInbound(sessionId) {
|
|
57
|
+
const pid = sessionPid(sessionId);
|
|
58
|
+
const args = launchArgs(pid);
|
|
59
|
+
const user = readJson(path.join(configDir(), 'settings.json')) || {};
|
|
60
|
+
const flagged = settingsFromFlag(args) || {};
|
|
61
|
+
const mode = flag(args, '--permission-mode') || user.permissions?.defaultMode || 'default';
|
|
62
|
+
// Launch flags beat user settings; a managed policy layer could still tighten either.
|
|
63
|
+
const inbound = flagged.crossSessionInbound ?? user.crossSessionInbound;
|
|
64
|
+
const bypassing = BYPASS_MODES.has(mode);
|
|
65
|
+
if (!bypassing) return { ok: true, mode, crossSessionInbound: inbound ?? null };
|
|
66
|
+
if (inbound === 'accept') return { ok: true, mode, crossSessionInbound: inbound };
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
mode,
|
|
70
|
+
crossSessionInbound: inbound ?? null,
|
|
71
|
+
reason: inbound === 'refuse'
|
|
72
|
+
? 'This session refuses messages from other local processes (crossSessionInbound is "refuse").'
|
|
73
|
+
: 'This session bypasses permission prompts, so Claude Code holds messages from other local '
|
|
74
|
+
+ 'processes for the user to approve instead of delivering them, and it sends no receipt to '
|
|
75
|
+
+ 'say so. Voice will appear to be sent and nothing will arrive.',
|
|
76
|
+
remedy: inbound === 'refuse'
|
|
77
|
+
? 'Change crossSessionInbound from "refuse" to "accept", or start the session in a permission '
|
|
78
|
+
+ 'mode that prompts.'
|
|
79
|
+
: 'Two ways out. Per session: start it with --settings \'{"crossSessionInbound":"accept"}\'. '
|
|
80
|
+
+ 'For every session on this machine: add "crossSessionInbound": "accept" to '
|
|
81
|
+
+ `${path.join(configDir(), 'settings.json')} — that takes effect immediately, releases any `
|
|
82
|
+
+ 'messages already held, and also lets any other local process post into all your sessions, '
|
|
83
|
+
+ 'which is the safeguard it removes. Or run the conversation in a prompting mode such as '
|
|
84
|
+
+ '--permission-mode auto.',
|
|
85
|
+
// Said plainly so nothing downstream reports this as certain.
|
|
86
|
+
confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
|
|
87
|
+
};
|
|
88
|
+
}
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Stdio MCP façade for one conversation. It holds no connection to the room: it starts or reuses
|
|
3
|
+
* the host's connector and keeps one local connection to it for as long as this session lives. */
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { inspectInbound } from './harness-claude.mjs';
|
|
11
|
+
|
|
12
|
+
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
13
|
+
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
14
|
+
const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url));
|
|
15
|
+
|
|
16
|
+
const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
|
|
17
|
+
- Call voice_connect only when the user asks to join the voice room or enable voice for this conversation; never as a side effect.
|
|
18
|
+
- Voice input arrives as a user message that starts with a JSON header ({"channel":"voice","session_id":...,"revision":...,"message_id":...}) followed by the user's literal words. Treat the header as opaque reply metadata; if the same message_id arrives twice, it is a redelivery: do not act on it again.
|
|
19
|
+
- For substantive work, one incoming voice message may receive multiple voice_say publications: an immediate acknowledgement that states what was understood and the next action, meaningful progress checkpoints while work continues, and a final result. Use the same original session_id and revision for every publication, with distinct utterances; do not manufacture filler or narrate every tool call.
|
|
20
|
+
- A progress publication is not itself a listening point. Divide substantive execution into bounded steps and, after each tool result or operational boundary, process newly arrived user input before starting the next step. Do not add artificial sleeps or fixed pauses.
|
|
21
|
+
- If a new user message arrives during active work, treat it as an addition, refinement, or replacement according to its meaning. Stop not-yet-started obsolete work, preserve completed work that remains useful, acknowledge the new interpretation before continuing, and do not later answer a stale request. A tool already running may finish before the correction takes effect; delegation is not a substitute for listening.
|
|
22
|
+
- A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
|
|
23
|
+
- A message with "channel":"room-control" is an instruction from the room (for example: continue in writing); it is not a voice turn to answer aloud.
|
|
24
|
+
- voice_status reports whether the room can currently reach this conversation.
|
|
25
|
+
- If voice_connect returns inbound.ok false, voice will look sent and never arrive: this harness holds or refuses messages posted by other local processes. Tell the user what inbound.reason says, offer inbound.remedy in your own words including what safeguard the machine-wide option removes, and let them choose. Do not change their settings without being asked to.`;
|
|
26
|
+
|
|
27
|
+
/** Who this façade speaks for, decided by what spawned it — never by the model. */
|
|
28
|
+
function identity(meta) {
|
|
29
|
+
if (process.env.CLAUDE_CODE_SESSION_ID && process.env.CLAUDE_CODE_MESSAGING_SOCKET) {
|
|
30
|
+
return { harness: 'claude', thread: process.env.CLAUDE_CODE_SESSION_ID,
|
|
31
|
+
delivery: { kind: 'claude-uds', socket: process.env.CLAUDE_CODE_MESSAGING_SOCKET, token: process.env.CLAUDE_CODE_MESSAGING_TOKEN || '' } };
|
|
32
|
+
}
|
|
33
|
+
let turn = meta?.['x-codex-turn-metadata'] || {};
|
|
34
|
+
if (typeof turn === 'string') { try { turn = JSON.parse(turn); } catch { turn = {}; } }
|
|
35
|
+
const codexThread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId || meta?.codex_thread_id || turn.thread_id || process.env.CODEX_THREAD_ID;
|
|
36
|
+
if (codexThread) {
|
|
37
|
+
const delivery = process.env.SIDEVOICE_DELIVERY_URL ? { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: codexThread } : { kind: 'codex-queue', thread: codexThread };
|
|
38
|
+
return { harness: 'codex', thread: codexThread, delivery };
|
|
39
|
+
}
|
|
40
|
+
if (process.env.SIDEVOICE_THREAD && process.env.SIDEVOICE_DELIVERY_URL) {
|
|
41
|
+
return { harness: process.env.SIDEVOICE_HARNESS || 'http', thread: process.env.SIDEVOICE_THREAD, delivery: { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: process.env.SIDEVOICE_THREAD } };
|
|
42
|
+
}
|
|
43
|
+
throw new Error('Cannot tell which conversation this is: not launched by Claude Code or Codex, and no SIDEVOICE_THREAD/SIDEVOICE_DELIVERY_URL set');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ----- one persistent connection to the connector -----
|
|
47
|
+
let ipc = null, ipcBuffer = '', ipcSerial = 0;
|
|
48
|
+
const ipcWaiting = new Map();
|
|
49
|
+
function connectIpc() {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const socket = net.createConnection(socketPath);
|
|
52
|
+
socket.once('error', reject);
|
|
53
|
+
socket.on('connect', () => {
|
|
54
|
+
socket.removeListener('error', reject);
|
|
55
|
+
socket.on('error', () => {});
|
|
56
|
+
socket.on('close', () => { if (ipc === socket) ipc = null; for (const w of ipcWaiting.values()) w.reject(new Error('Connector went away')); ipcWaiting.clear(); });
|
|
57
|
+
socket.on('data', chunk => {
|
|
58
|
+
ipcBuffer += chunk; let index;
|
|
59
|
+
while ((index = ipcBuffer.indexOf('\n')) >= 0) {
|
|
60
|
+
const line = ipcBuffer.slice(0, index); ipcBuffer = ipcBuffer.slice(index + 1);
|
|
61
|
+
let reply; try { reply = JSON.parse(line); } catch { continue; }
|
|
62
|
+
const waiting = ipcWaiting.get(reply.id); if (!waiting) continue; ipcWaiting.delete(reply.id);
|
|
63
|
+
reply.ok ? waiting.resolve(reply.result) : waiting.reject(new Error(reply.error));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
ipc = socket; resolve(socket);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function ensureConnector() {
|
|
71
|
+
if (ipc) return ipc;
|
|
72
|
+
try { return await connectIpc(); } catch {}
|
|
73
|
+
const child = spawn(process.execPath, [connectorPath], { detached: true, stdio: 'ignore', env: process.env });
|
|
74
|
+
child.unref();
|
|
75
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
76
|
+
await new Promise(r => setTimeout(r, 100));
|
|
77
|
+
try { return await connectIpc(); } catch {}
|
|
78
|
+
}
|
|
79
|
+
throw new Error('The Sidevoice connector did not start (is this host paired? see docs/INSTALL.md)');
|
|
80
|
+
}
|
|
81
|
+
async function rpc(method, params) {
|
|
82
|
+
await ensureConnector();
|
|
83
|
+
const id = ++ipcSerial;
|
|
84
|
+
return new Promise((resolve, reject) => { ipcWaiting.set(id, { resolve, reject }); ipc.write(JSON.stringify({ id, method, params }) + '\n'); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ----- tools -----
|
|
88
|
+
const tools = [
|
|
89
|
+
{ name: 'voice_connect', description: 'Connect this conversation to the voice room. Only on an explicit request to join or enable voice.',
|
|
90
|
+
inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Short label for this conversation in the room' } }, additionalProperties: false } },
|
|
91
|
+
{ name: 'voice_say', description: 'Publish a concise spoken version of your reply to the room, with the session_id and revision from the voice message header.',
|
|
92
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string' }, session_id: { type: 'string' }, revision: { type: 'integer', minimum: 0 }, utterance_id: { type: 'string' }, language: { type: 'string', enum: ['es', 'en', 'fr', 'it', 'pt', 'hi'] } }, required: ['text', 'session_id', 'revision'], additionalProperties: false } },
|
|
93
|
+
{ name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
94
|
+
{ name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
95
|
+
];
|
|
96
|
+
let binding = null;
|
|
97
|
+
async function invoke(name, args, meta) {
|
|
98
|
+
if (name === 'voice_status') {
|
|
99
|
+
const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [] };
|
|
100
|
+
const inbound = binding?.harness === 'claude' ? inspectInbound(binding.client_ref) : { ok: true };
|
|
101
|
+
return { joined: !!binding, room_reachable: status.connected, room_error: status.room_error || null,
|
|
102
|
+
binding_id: binding?.binding_id || null, harness: binding?.harness || null, inbound };
|
|
103
|
+
}
|
|
104
|
+
if (name === 'voice_connect') {
|
|
105
|
+
const who = identity(meta);
|
|
106
|
+
const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
|
|
107
|
+
// Refuse rather than join a room we cannot hear from: a conversation whose harness holds
|
|
108
|
+
// what the room posts would sit in the list looking present while the user talks to nobody.
|
|
109
|
+
const inbound = who.harness === 'claude' ? inspectInbound(who.thread) : { ok: true };
|
|
110
|
+
if (inbound.ok === false) {
|
|
111
|
+
const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
|
|
112
|
+
error.data = { inbound };
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread, title, delivery: who.delivery, inbound });
|
|
116
|
+
binding = { ...result, harness: who.harness, client_ref: who.thread };
|
|
117
|
+
return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
|
|
118
|
+
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, inbound };
|
|
119
|
+
}
|
|
120
|
+
if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
|
|
121
|
+
if (name === 'voice_say') {
|
|
122
|
+
const result = await rpc('publish', { binding_id: binding.binding_id, client_ref: binding.client_ref, text: args.text, session_id: args.session_id, revision: args.revision, utterance_id: args.utterance_id, language: args.language });
|
|
123
|
+
return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
|
|
124
|
+
}
|
|
125
|
+
if (name === 'voice_disconnect') { const result = await rpc('unregister', { binding_id: binding.binding_id }); binding = null; return { status: 'left', room_reachable: result.connected }; }
|
|
126
|
+
throw new Error('Unknown tool');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ----- JSON-RPC over stdio -----
|
|
130
|
+
let input = '';
|
|
131
|
+
process.stdin.setEncoding('utf8');
|
|
132
|
+
process.stdin.on('data', async chunk => {
|
|
133
|
+
input += chunk;
|
|
134
|
+
while (input.includes('\n')) {
|
|
135
|
+
const index = input.indexOf('\n'); const line = input.slice(0, index); input = input.slice(index + 1);
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
let request; try { request = JSON.parse(line); } catch { continue; }
|
|
138
|
+
if (request.id === undefined) continue; // notifications need no answer
|
|
139
|
+
let result, error;
|
|
140
|
+
try {
|
|
141
|
+
if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'sidevoice', version: '0.2.0' }, instructions: INSTRUCTIONS };
|
|
142
|
+
else if (request.method === 'tools/list') result = { tools };
|
|
143
|
+
else if (request.method === 'tools/call') { const value = await invoke(request.params.name, request.params.arguments || {}, request.params._meta); result = { content: [{ type: 'text', text: JSON.stringify(value) }] }; }
|
|
144
|
+
else if (request.method === 'ping') result = {};
|
|
145
|
+
else throw Object.assign(new Error('Method not found'), { code: -32601 });
|
|
146
|
+
} catch (e) { error = { code: e.code || -32603, message: e.message }; }
|
|
147
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, ...(error ? { error } : { result }) }) + '\n');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
process.stdin.on('end', () => { ipc?.end(); process.exit(0); });
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sidevoice/uplink",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Sidevoice client side: the stdio MCP server your agent uses, one outbound uplink per machine to the room, one-time pairing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"sidevoice": "./cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"cli.mjs",
|
|
15
|
+
"mcp.mjs",
|
|
16
|
+
"connector.mjs",
|
|
17
|
+
"adapters.mjs",
|
|
18
|
+
"harness-claude.mjs",
|
|
19
|
+
"pair.mjs",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/test_connector.mjs"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/rubasace/sidevoice.git",
|
|
28
|
+
"directory": "packages/connector"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/pair.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** One-time pairing: redeem the code shown by the room for this host's connector credential. */
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const [room, code] = process.argv.slice(2);
|
|
8
|
+
if (!room || !code) { console.error('usage: pair.mjs <room-url> <pairing-code>'); process.exit(2); }
|
|
9
|
+
const base = new URL(room);
|
|
10
|
+
const response = await fetch(new URL('/api/connectors/pair', base), {
|
|
11
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, host: os.hostname() }),
|
|
12
|
+
signal: AbortSignal.timeout(15_000),
|
|
13
|
+
});
|
|
14
|
+
const body = await response.json().catch(() => ({}));
|
|
15
|
+
if (!response.ok) { console.error('Pairing failed: ' + (body.detail || response.status)); process.exit(1); }
|
|
16
|
+
const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
17
|
+
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
18
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const file = path.join(dataDir, 'credentials.json');
|
|
20
|
+
writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
|
|
21
|
+
console.log(`Paired with ${base.origin} as connector ${body.connector_id}; credential saved to ${file}`);
|