@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 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
- const loopback = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
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
- /** What a harness should run to start the server. From a checkout it names this copy, so a machine that
23
- * installed from source keeps working when the published version moves; otherwise the pinned package. */
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 fromSource = env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
26
- return fromSource
27
- ? { command: 'node', args: [path.join(here, 'cli.mjs'), 'mcp'] }
28
- : { command: 'npx', args: ['-y', `@sidevoice/uplink@${VERSION}`, 'mcp'] };
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: '0.2.0' }, instructions: INSTRUCTIONS };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidevoice/uplink",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Sidevoice client side: the stdio MCP server your agent uses, one outbound uplink per machine to the room, one-time pairing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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),