@sidevoice/uplink 0.4.0 → 0.4.2
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/connector.mjs +6 -3
- package/install.mjs +68 -7
- package/mcp.mjs +13 -2
- package/package.json +1 -1
- package/pair.mjs +12 -0
package/connector.mjs
CHANGED
|
@@ -9,6 +9,10 @@ import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync
|
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
|
|
11
11
|
import { harnessFor } from './harnesses.mjs';
|
|
12
|
+
import { privateNetwork } from './pair.mjs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
|
|
12
16
|
|
|
13
17
|
export const PROTOCOL = 1;
|
|
14
18
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
@@ -27,8 +31,7 @@ function credentials() {
|
|
|
27
31
|
const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
|
|
28
32
|
if (!url || !connector_id || !token) throw new Error(`Not paired with any room (looked in ${credentialsPath}): the conversation's voice_pair, or sidevoice pair <room-url> <code>, with the code the room shows`);
|
|
29
33
|
const parsed = new URL(url);
|
|
30
|
-
|
|
31
|
-
if (parsed.protocol !== 'wss:' && !loopback) throw new Error('The room URL must be wss:// unless it is loopback');
|
|
34
|
+
if (parsed.protocol !== 'wss:' && !privateNetwork(parsed.hostname)) throw new Error('The room URL must be wss:// unless it stays on this machine or inside its cluster');
|
|
32
35
|
const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
|
|
33
36
|
return { url, connector_id, token, room: room.origin };
|
|
34
37
|
}
|
|
@@ -219,7 +222,7 @@ async function receive(frame) {
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
function snapshot() {
|
|
222
|
-
return { host: hostId, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
225
|
+
return { host: hostId, version: VERSION, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
223
226
|
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
|
|
224
227
|
({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
|
|
225
228
|
}
|
package/install.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* What it does not do is decide for the person: it never edits a machine-wide Codex configuration it
|
|
11
11
|
* does not own, and it never relaxes Claude Code's inbound safeguard — those it prints, with the reason. */
|
|
12
12
|
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import net from 'node:net';
|
|
14
15
|
import os from 'node:os';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,13 +20,66 @@ import { install as installSkill, skillsDir } from './skill.mjs';
|
|
|
19
20
|
|
|
20
21
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const VERSION = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).version;
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/** The files that make up this package, copied as they are. */
|
|
24
|
+
const PACKAGE_FILES = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).files.concat('package.json');
|
|
25
|
+
|
|
26
|
+
function fromSource(env) {
|
|
27
|
+
if (env.SIDEVOICE_INSTALL_FROM_SOURCE === '0') return false;
|
|
28
|
+
return env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where installed copies live: one directory per version, under the XDG data home. */
|
|
32
|
+
export function copiesDir(env = process.env) {
|
|
33
|
+
return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'sidevoice');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a harness should run to start the server. From a checkout it names that checkout, so a machine that
|
|
37
|
+
* installed from source keeps working when the published version moves. Otherwise it names a copy of this
|
|
38
|
+
* package that install placed on disk — never `npx`: a session start is not the moment to resolve a package
|
|
39
|
+
* (a cold cache, a bin whose name differs from the package's, a 30 s startup budget; one session found no
|
|
40
|
+
* `sidevoice` binary at all, 2026-09-21). */
|
|
24
41
|
export function serverCommand(env = process.env) {
|
|
25
|
-
const
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
const cli = fromSource(env) ? path.join(here, 'cli.mjs') : path.join(copiesDir(env), VERSION, 'cli.mjs');
|
|
43
|
+
return { command: 'node', args: [cli, 'mcp'] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Put this version's files where serverCommand points, and drop the other versions: an installed copy is
|
|
47
|
+
* disposable and there is one current one. From a checkout nothing is copied. */
|
|
48
|
+
export function materialize(env = process.env) {
|
|
49
|
+
if (fromSource(env)) return { action: 'checkout', target: here };
|
|
50
|
+
const root = copiesDir(env), target = path.join(root, VERSION);
|
|
51
|
+
mkdirSync(target, { recursive: true });
|
|
52
|
+
for (const file of PACKAGE_FILES) {
|
|
53
|
+
const source = path.join(here, file);
|
|
54
|
+
if (existsSync(source)) cpSync(source, path.join(target, file), { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
const removed = [];
|
|
57
|
+
for (const name of readdirSync(root)) {
|
|
58
|
+
if (name !== VERSION) { rmSync(path.join(root, name), { recursive: true, force: true }); removed.push(name); }
|
|
59
|
+
}
|
|
60
|
+
return { action: 'copied', target, removed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The connector that holds this machine's socket, if any, and which version it is: a façade uses whatever
|
|
64
|
+
* connector is running, so one left over from before an upgrade serves every new session with old code. */
|
|
65
|
+
export function runningConnector(env = process.env) {
|
|
66
|
+
const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
67
|
+
const socketPath = env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
68
|
+
let pid = null;
|
|
69
|
+
try { pid = Number(readFileSync(socketPath + '.lock', 'utf8')) || null; } catch { return null; }
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
const socket = net.createConnection(socketPath);
|
|
72
|
+
const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
|
|
73
|
+
const timer = setTimeout(() => done(null), 1500);
|
|
74
|
+
let buffer = '';
|
|
75
|
+
socket.on('error', () => done(null));
|
|
76
|
+
socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method: 'status', params: {} }) + '\n'));
|
|
77
|
+
socket.on('data', chunk => {
|
|
78
|
+
buffer += chunk; const index = buffer.indexOf('\n'); if (index < 0) return;
|
|
79
|
+
try { const reply = JSON.parse(buffer.slice(0, index)); done({ pid, version: reply.result?.version || null, bindings: reply.result?.bindings?.length ?? null }); }
|
|
80
|
+
catch { done({ pid, version: null, bindings: null }); }
|
|
81
|
+
});
|
|
82
|
+
});
|
|
29
83
|
}
|
|
30
84
|
|
|
31
85
|
export function flag(argv, name) {
|
|
@@ -117,6 +171,8 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
|
|
|
117
171
|
const done = [], next = [];
|
|
118
172
|
|
|
119
173
|
done.push(`Sidevoice ${VERSION}.`);
|
|
174
|
+
const copy = materialize(env);
|
|
175
|
+
if (copy.action === 'copied') done.push(`Copied this version to ${copy.target}${copy.removed.length ? ` (removed: ${copy.removed.join(', ')})` : ''}.`);
|
|
120
176
|
if (harnesses.includes('claude')) {
|
|
121
177
|
registerWithClaude(done, env);
|
|
122
178
|
const outcome = installSkill(skillsDir([], env));
|
|
@@ -126,6 +182,11 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
|
|
|
126
182
|
const paired = pairedRoom(env);
|
|
127
183
|
done.push(paired ? `This machine is paired with ${paired.origin} (connector ${paired.connector_id}).`
|
|
128
184
|
: 'This machine is not paired with any room yet.');
|
|
185
|
+
const running = await runningConnector(env);
|
|
186
|
+
if (running && running.version !== VERSION) {
|
|
187
|
+
next.push(`A connector from ${running.version ? 'version ' + running.version : 'an older version'} is still running (pid ${running.pid}) and every conversation on this machine uses it. ` +
|
|
188
|
+
`It exits by itself 15 s after the last conversation leaves it; to switch now: kill ${running.pid}, then join again from each conversation.`);
|
|
189
|
+
}
|
|
129
190
|
|
|
130
191
|
if (harnesses.includes('claude')) {
|
|
131
192
|
next.push('In a conversation, run /voice-room to join the room.' +
|
package/mcp.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { advertisedCapabilities, capabilityState, SUPPORTED } from './harness-contract.mjs';
|
|
11
11
|
import { harnessFor, identifyHarness } from './harnesses.mjs';
|
|
12
12
|
import { pair, pairedRoom } from './pair.mjs';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
13
16
|
|
|
14
17
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
15
18
|
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
@@ -91,6 +94,11 @@ function pairingNeeded(room, paired) {
|
|
|
91
94
|
if (target && target !== paired.origin) return `This machine is paired with ${paired.origin}, not ${target}. One room per machine: to switch, ask the user for the pairing code that ${target} shows under "Emparejar conector" and call voice_pair (it replaces the current pairing); to stay, call voice_connect without a room.`;
|
|
92
95
|
return null;
|
|
93
96
|
}
|
|
97
|
+
/** A connector from another version serves this conversation with that version's behaviour. */
|
|
98
|
+
function versionNote(connectorVersion) {
|
|
99
|
+
if (!ipc || connectorVersion === VERSION) return {};
|
|
100
|
+
return { note: `The connector running on this machine is ${connectorVersion ? 'version ' + connectorVersion : 'older than this server'}; this conversation runs ${VERSION}. It exits 15 s after the last conversation leaves it; until then behaviour is that version's.` };
|
|
101
|
+
}
|
|
94
102
|
function inboundFor(harness, thread) {
|
|
95
103
|
return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
|
|
96
104
|
}
|
|
@@ -103,6 +111,7 @@ async function invoke(name, args, meta) {
|
|
|
103
111
|
const module = binding ? harnessFor(binding.harness) : null;
|
|
104
112
|
const inbound = binding ? inboundFor(module, binding.client_ref) : null;
|
|
105
113
|
return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null,
|
|
114
|
+
version: VERSION, connector_version: status.version || null, ...versionNote(status.version),
|
|
106
115
|
binding_id: binding?.binding_id || null, harness: binding?.harness || null,
|
|
107
116
|
capabilities: binding?.capabilities || null, inbound,
|
|
108
117
|
...(closed ? { closed_by_room: true, note: 'The user closed this conversation\'s voice channel from the room. Continue in writing; call voice_connect again only if they ask for voice.' } : {}) };
|
|
@@ -141,8 +150,10 @@ async function invoke(name, args, meta) {
|
|
|
141
150
|
const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
|
|
142
151
|
title, delivery: who.delivery, inbound, capabilities, engine });
|
|
143
152
|
binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
|
|
153
|
+
let connectorVersion = null; try { connectorVersion = (await rpc('status', {})).version || null; } catch {}
|
|
144
154
|
return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
|
|
145
|
-
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound
|
|
155
|
+
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound,
|
|
156
|
+
version: VERSION, connector_version: connectorVersion, ...versionNote(connectorVersion) };
|
|
146
157
|
}
|
|
147
158
|
if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
|
|
148
159
|
|
|
@@ -175,7 +186,7 @@ process.stdin.on('data', async chunk => {
|
|
|
175
186
|
if (request.id === undefined) continue; // notifications need no answer
|
|
176
187
|
let result, error;
|
|
177
188
|
try {
|
|
178
|
-
if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'sidevoice', version:
|
|
189
|
+
if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'sidevoice', version: VERSION }, instructions: INSTRUCTIONS };
|
|
179
190
|
else if (request.method === 'tools/list') result = { tools };
|
|
180
191
|
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) }] }; }
|
|
181
192
|
else if (request.method === 'ping') result = {};
|
package/package.json
CHANGED
package/pair.mjs
CHANGED
|
@@ -12,6 +12,15 @@ export function dataDir(env = process.env) {
|
|
|
12
12
|
return env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Where a plaintext connection is acceptable: the token must never cross a network we do not own. Loopback,
|
|
16
|
+
* and a Kubernetes service name (`<svc>.<ns>.svc`, `<svc>.<ns>.svc.<cluster domain>`), which by construction
|
|
17
|
+
* resolves only inside the cluster and is routed there. Anything else — a private IP included — needs TLS: a
|
|
18
|
+
* host we cannot classify is not a reason to send a credential in clear. */
|
|
19
|
+
export function privateNetwork(hostname) {
|
|
20
|
+
if (['127.0.0.1', 'localhost', '::1', '[::1]'].includes(hostname)) return true;
|
|
21
|
+
return /^[a-z0-9-]+\.[a-z0-9-]+\.svc(\.[a-z0-9.-]+)?$/i.test(hostname);
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/** The room's http(s) origin from the socket address the credential stores. */
|
|
16
25
|
export function roomOrigin(wsUrl) {
|
|
17
26
|
const url = new URL(wsUrl); url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; return url.origin;
|
|
@@ -29,6 +38,9 @@ export function pairedRoom(env = process.env) {
|
|
|
29
38
|
/** Redeem a code for this host's credential. Returns where it was written. */
|
|
30
39
|
export async function pair(room, code, env = process.env) {
|
|
31
40
|
const base = new URL(room);
|
|
41
|
+
if (base.protocol !== 'https:' && !privateNetwork(base.hostname)) {
|
|
42
|
+
throw new Error(`${base.origin} is reached in clear over a network this machine does not own; the room must be https:// there (loopback and Kubernetes service names are the exceptions).`);
|
|
43
|
+
}
|
|
32
44
|
const response = await fetch(new URL('/api/connectors/pair', base), {
|
|
33
45
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
34
46
|
body: JSON.stringify({ code, host: os.hostname() }), signal: AbortSignal.timeout(15_000),
|