@sidevoice/uplink 0.4.1 → 0.4.3

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 CHANGED
@@ -3,8 +3,9 @@
3
3
  The client side. One bin (`sidevoice`), these entry points:
4
4
 
5
5
  - `install` — registers the MCP server with the harness (re-pinned to this
6
- version when an older one was registered) and installs the skill. Pairs with
7
- nothing; reports whether the machine is paired and with which room.
6
+ version when an older one was registered) and removes a skill copy an earlier
7
+ version left. Pairs with nothing; reports whether the machine is paired and
8
+ with which room.
8
9
  - `mcp` — the stdio MCP server a harness starts. One per conversation. Exposes
9
10
  `voice_connect`, `voice_pair`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
10
11
  operational instructions in its `initialize` result. It never talks to the
package/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- /** `sidevoice <install|mcp|pair|connector|skill> …` — one bin, five entry points. */
2
+ /** `sidevoice <install|uninstall|mcp|pair|connector|skill> …` — one bin, six entry points. */
3
3
  const [, , command] = process.argv;
4
- const entries = { install: './install.mjs', mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', skill: './skill.mjs' };
5
- if (!entries[command]) { console.error('usage: sidevoice <install|mcp|pair|connector|skill> [args]'); process.exit(2); }
4
+ const entries = { install: './install.mjs', uninstall: './install.mjs', mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', skill: './skill.mjs' };
5
+ if (!entries[command]) { console.error('usage: sidevoice <install|uninstall|mcp|pair|connector|skill> [args]'); process.exit(2); }
6
6
  process.argv.splice(2, 1);
7
7
  if (command === 'skill') process.env.SIDEVOICE_SKILL_MAIN = '1';
8
8
  if (command === 'pair') process.env.SIDEVOICE_PAIR_MAIN = '1';
9
9
  if (command === 'install') process.env.SIDEVOICE_INSTALL_MAIN = '1';
10
+ if (command === 'uninstall') process.env.SIDEVOICE_UNINSTALL_MAIN = '1';
10
11
  await import(entries[command]);
package/connector.mjs CHANGED
@@ -5,11 +5,15 @@
5
5
  import net from 'node:net';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
8
+ import { appendFileSync, mkdirSync, openSync, closeSync, statSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
9
9
  import { randomUUID } from 'node:crypto';
10
+ import { execFileSync } from 'node:child_process';
10
11
  import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
11
12
  import { harnessFor } from './harnesses.mjs';
12
13
  import { privateNetwork } from './pair.mjs';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
13
17
 
14
18
  export const PROTOCOL = 1;
15
19
  const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
@@ -19,6 +23,21 @@ const outboxPath = path.join(dataDir, 'outbox.json');
19
23
  const credentialsPath = process.env.SIDEVOICE_CREDENTIALS || path.join(dataDir, 'credentials.json');
20
24
  const idleMs = Number(process.env.SIDEVOICE_CONNECTOR_IDLE_MS || 15_000);
21
25
  const hostId = process.env.SIDEVOICE_HOST_ID || os.hostname();
26
+ const logPath = process.env.SIDEVOICE_CONNECTOR_LOG || path.join(dataDir, 'connector.log');
27
+ const LOG_MAX = 1 << 20;
28
+
29
+ /** One line per event, to stderr and to `connector.log` in the data dir: the façade starts this process
30
+ * with its output discarded, so the file is the only record of a connector nobody ran by hand. Rolls
31
+ * over once, at 1 MB. */
32
+ function log(line) {
33
+ const stamped = `${new Date().toISOString()} [sidevoice] ${line}`;
34
+ console.error(stamped);
35
+ try {
36
+ let size = 0; try { size = statSync(logPath).size; } catch {}
37
+ if (size > LOG_MAX) renameSync(logPath, logPath + '.1');
38
+ appendFileSync(logPath, stamped + '\n', { mode: 0o600 });
39
+ } catch {}
40
+ }
22
41
 
23
42
  function credentials() {
24
43
  let saved = {};
@@ -33,7 +52,16 @@ function credentials() {
33
52
  return { url, connector_id, token, room: room.origin };
34
53
  }
35
54
 
36
- function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
55
+ /** Whether the pid in the lock is a live Sidevoice connector not merely a live pid. Pids are reused,
56
+ * and on macOS a pid that now belongs to another user answers EPERM, which used to count as alive: a
57
+ * stale lock then made every new connector exit at once, silently (a laptop, 2026-09-21). */
58
+ function connectorAlive(pid) {
59
+ try { process.kill(pid, 0); } catch (error) { if (error.code !== 'EPERM') return false; }
60
+ try {
61
+ const args = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim();
62
+ return /(^|[\s/])connector(\.mjs)?(\s|$)/.test(args); // `…/connector.mjs` from a checkout, `sidevoice connector` from a package; not test_connector.mjs
63
+ } catch { return true; } // No ps to ask: a live pid is taken at its word.
64
+ }
37
65
  function acquireLock() {
38
66
  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
39
67
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -41,8 +69,12 @@ function acquireLock() {
41
69
  catch (error) {
42
70
  if (error.code !== 'EEXIST') throw error;
43
71
  let pid = 0; try { pid = Number(readFileSync(lockPath, 'utf8')); } catch {}
44
- if (pid && alive(pid)) return false; // A live connector holds it: we are redundant.
45
- try { unlinkSync(lockPath); } catch {} // Stale lock from a dead process.
72
+ if (pid && connectorAlive(pid)) { // A live connector holds it: we are redundant, and we say so.
73
+ log(`a connector is already running (pid ${pid}, lock ${lockPath}); this one exits`);
74
+ return false;
75
+ }
76
+ log(`stale lock ${lockPath} (pid ${pid || '?'} is not a connector); taking over`);
77
+ try { unlinkSync(lockPath); } catch {}
46
78
  }
47
79
  }
48
80
  return false;
@@ -56,6 +88,7 @@ const closedByRoom = new Map(); // client_ref -> reason: the user closed that
56
88
  const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
57
89
  let outbox = []; // speech frames not yet confirmed by the room
58
90
  let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
91
+ let socketError = null; // why the last attempt to reach the room failed, for whoever asks status
59
92
  let creds;
60
93
 
61
94
  function loadOutbox() { try { outbox = JSON.parse(readFileSync(outboxPath, 'utf8')); if (!Array.isArray(outbox)) outbox = []; } catch { outbox = []; } }
@@ -111,7 +144,7 @@ function watch(binding) {
111
144
  if (!readReported.has(header.message_id)) {
112
145
  readReported.add(header.message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
113
146
  send({ type: 'input.read', binding_id: binding.binding_id, message_id: header.message_id, session_id: header.session_id, revision: header.revision, turn_id: turn_id || null });
114
- console.error(`[sidevoice] ${binding.thread} read ${header.message_id}`);
147
+ log(`${binding.thread} read ${header.message_id} (session ${header.session_id} rev ${header.revision}, turn ${turn_id || '?'})`);
115
148
  }
116
149
  if (header.channel !== 'voice') return;
117
150
  binding.turn = { turn_id: turn_id || null, session_id: header.session_id, revision: header.revision };
@@ -140,22 +173,25 @@ function open() {
140
173
  send({ type: 'connector.hello', protocol: PROTOCOL, connector_id: creds.connector_id, token: creds.token, host: hostId });
141
174
  });
142
175
  socket.addEventListener('message', event => { receive(JSON.parse(String(event.data))).catch(error => send({ type: 'connector.error', error: error.message })); });
143
- const lost = () => { if (ws === socket) { ws = null; connected = false; } reconnect(); };
176
+ const lost = why => { if (ws === socket) { ws = null; connected = false; } if (why) { socketError = { ...why, at: new Date().toISOString(), attempt: reconnectAttempt }; log('room unreachable: ' + JSON.stringify(why)); } reconnect(); };
144
177
  // A refused connection surfaces as 'error' with no 'close', and the dead socket stays
145
- // CONNECTING forever: forget it, or open() would never make another one.
146
- socket.addEventListener('close', lost);
147
- socket.addEventListener('error', lost);
178
+ // CONNECTING forever: forget it, or open() would never make another one. Whatever the runtime
179
+ // says about it is kept: "not reachable" alone told a person nothing (2026-09-21).
180
+ socket.addEventListener('close', event => lost(connected ? null : { close_code: event.code, reason: event.reason || null }));
181
+ socket.addEventListener('error', event => lost({ error: event.error?.message || event.message || 'connection failed' }));
148
182
  }
149
183
  function reconnect() {
150
184
  if (closed || reconnectTimer) return;
151
185
  const delay = Math.min(10_000, 250 * 2 ** Math.min(reconnectAttempt++, 6));
186
+ if (reconnectAttempt <= 3 || reconnectAttempt % 10 === 0) log(`room not connected; retrying in ${delay} ms (attempt ${reconnectAttempt})`);
152
187
  reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, delay);
153
188
  }
154
189
 
155
190
  async function receive(frame) {
156
191
  switch (frame.type) {
157
192
  case 'connector.welcome':
158
- connected = true; reconnectAttempt = 0; lastError = null;
193
+ connected = true; reconnectAttempt = 0; lastError = null; socketError = null;
194
+ log(`connected to ${creds.room} as ${creds.connector_id} (protocol ${frame.protocol ?? PROTOCOL}); ${bindings.size} binding(s) to re-register, ${outbox.length} queued speech`);
159
195
  for (const binding of bindings.values()) {
160
196
  // `local-*` is only a connector-side placeholder while the first
161
197
  // registration waits for the room to mint its durable binding id.
@@ -173,10 +209,12 @@ async function receive(frame) {
173
209
  const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
174
210
  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); }
175
211
  if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
212
+ if (binding) log(`room registered ${binding.thread} as ${frame.binding_id} ("${binding.title || ''}")`);
176
213
  registering.get(frame.client_ref)?.resolve(frame); return;
177
214
  }
178
- case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
215
+ case 'binding.rejected': log(`room rejected ${frame.client_ref}: ${frame.error || 'no reason'}`); registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
179
216
  case 'speech.published': {
217
+ log(`speech ${frame.utterance_id || frame.event_id} ${frame.status || 'published'}${frame.reason ? ' (' + frame.reason + ')' : ''}`);
180
218
  outbox = outbox.filter(speech => speech.event_id !== frame.event_id); saveOutbox();
181
219
  publishing.get(frame.event_id)?.resolve(frame); return;
182
220
  }
@@ -195,11 +233,11 @@ async function receive(frame) {
195
233
  try {
196
234
  const harness = harnessFor(binding.harness);
197
235
  const outcome = await harness.deliver(binding.delivery, frame);
198
- console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
236
+ log(`delivered ${frame.event_id} (${frame.message_id}) to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
199
237
  send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
200
238
  } catch (error) {
201
239
  binding.pending?.delete(frame.message_id);
202
- console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
240
+ log(`delivery of ${frame.event_id} to ${binding.thread} failed: ${error.message}`);
203
241
  send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
204
242
  }
205
243
  });
@@ -211,23 +249,24 @@ async function receive(frame) {
211
249
  if (!binding) return;
212
250
  bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
213
251
  closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
214
- console.error(`[sidevoice] room closed voice for ${binding.thread}`);
252
+ log(`room closed voice for ${binding.thread} (${frame.reason || 'closed_from_room'})`);
215
253
  scheduleExit(); return;
216
254
  }
217
- case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
255
+ case 'connector.error': lastError = frame.error; log('room says: ' + frame.error); return;
218
256
  }
219
257
  }
220
258
 
221
259
  function snapshot() {
222
- return { host: hostId, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
260
+ return { host: hostId, version: VERSION, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, socket_error: socketError, closed_by_room: [...closedByRoom.keys()],
223
261
  bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
224
262
  ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
225
263
  }
226
264
  function scheduleExit() {
227
265
  if (idleTimer) clearTimeout(idleTimer);
228
- idleTimer = setTimeout(() => { if (clients.size === 0 && bindings.size === 0) shutdown(); }, idleMs);
266
+ idleTimer = setTimeout(() => { if (clients.size === 0 && bindings.size === 0) { log(`idle for ${idleMs} ms with no conversation; exiting`); shutdown(); } }, idleMs);
229
267
  }
230
268
  function shutdown() {
269
+ if (!closed) log(`shutting down (${bindings.size} binding(s), ${clients.size} façade(s))`);
231
270
  closed = true; clearTimeout(reconnectTimer); clearTimeout(idleTimer);
232
271
  try { ws?.close(); } catch {}
233
272
  server.close();
@@ -249,6 +288,7 @@ async function command(client, input) {
249
288
  return { binding_id: existing.binding_id, thread, connected };
250
289
  }
251
290
  const local_id = 'local-' + randomUUID();
291
+ log(`${harness} ${thread} joins ("${title || ''}", delivery ${delivery.kind}, inbound ${inbound ? (inbound.ok ? 'ok' : 'held') : 'n/a'})`);
252
292
  const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
253
293
  bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
254
294
  const frame = await new Promise((resolve, reject) => {
@@ -275,7 +315,7 @@ async function command(client, input) {
275
315
  }
276
316
  case 'unregister': {
277
317
  const binding = bindings.get(params.binding_id);
278
- if (binding) { bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
318
+ if (binding) { log(`${binding.thread} leaves`); bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
279
319
  scheduleExit(); return snapshot();
280
320
  }
281
321
  case 'status': return snapshot();
@@ -286,6 +326,7 @@ async function command(client, input) {
286
326
  function serve(socket) {
287
327
  const client = { socket, bindings: new Set() };
288
328
  clients.add(client); clearTimeout(idleTimer);
329
+ log(`façade attached (${clients.size} now)`);
289
330
  let buffer = '';
290
331
  socket.on('data', chunk => {
291
332
  buffer += chunk;
@@ -302,6 +343,7 @@ function serve(socket) {
302
343
  socket.on('error', () => {});
303
344
  socket.on('close', () => {
304
345
  clients.delete(client);
346
+ log(`façade detached (${clients.size} left); dropping ${client.bindings.size} binding(s)`);
305
347
  // The façade is gone: so is every conversation it spoke for.
306
348
  for (const binding of client.bindings) { bindings.delete(binding.binding_id); unwatch(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
307
349
  scheduleExit();
@@ -310,7 +352,9 @@ function serve(socket) {
310
352
 
311
353
  creds = credentials();
312
354
  if (!acquireLock()) process.exit(0);
355
+ log(`connector ${VERSION} starting: pid ${process.pid}, host ${hostId}, room ${creds.room}, socket ${socketPath}, log ${logPath}`);
313
356
  loadOutbox();
357
+ if (outbox.length) log(`${outbox.length} speech frame(s) waiting in the outbox`);
314
358
  try { unlinkSync(socketPath); } catch {}
315
359
  const server = net.createServer(serve);
316
360
  await new Promise((resolve, reject) => server.once('error', reject).listen(socketPath, resolve));
@@ -55,11 +55,11 @@ export function envelope(event) {
55
55
  return JSON.stringify(header) + '\n\n' + event.text + note;
56
56
  }
57
57
 
58
- /** What the conversation is asked at the moment it reads a voice message. */
58
+ /** What the conversation is asked at the moment it reads a voice message: one line that points at the
59
+ * server's instructions, which are already in context — not a copy of them. The harness adds its own
60
+ * wrapper around a cross-session message; ours stays small. */
59
61
  export function nudge(header) {
60
- return `[Sidevoice] A voice message from the room (session_id "${header.session_id}", revision ${header.revision}). `
61
- + 'Before any other tool, publish a short spoken acknowledgement with voice_say that says what you understood and what you will do next, '
62
- + 'using that session_id and revision; then continue the work and publish the result by voice as well.';
62
+ return `[Sidevoice] Voice from the room: acknowledge with voice_say (session_id "${header.session_id}", revision ${header.revision}) before any other tool, then work and reply by voice, as the sidevoice server's instructions say.`;
63
63
  }
64
64
 
65
65
  /** The header at the front of a delivered message, or null when the text is not one of ours. Works on
package/install.mjs CHANGED
@@ -10,22 +10,76 @@
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';
17
18
  import { pairedRoom } from './pair.mjs';
18
- import { install as installSkill, skillsDir } from './skill.mjs';
19
+ import { remove as removeSkill, skillsDir, status as skillStatus } 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,20 +171,27 @@ 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
- const outcome = installSkill(skillsDir([], env));
123
- done.push(`Skill ${outcome.action} at ${outcome.target}.`);
178
+ // The join shortcut is a prompt the server offers; a skill copy from an earlier version is taken away.
179
+ if (skillStatus(skillsDir([], env)).state === 'installed') done.push(`Removed the voice-room skill copy at ${removeSkill(skillsDir([], env)).target}: the server offers it as the prompt /mcp__sidevoice__voice-room.`);
124
180
  }
125
181
 
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
- next.push('In a conversation, run /voice-room to join the room.' +
192
+ next.push('In a conversation, ask to join the voice room (or run /mcp__sidevoice__voice-room).' +
132
193
  (paired ? '' : ' The first time, the conversation asks you for the room\'s address and the one-time code the room shows under "Emparejar conector".'));
133
- next.push('Sessions already open need a restart before they can see the skill.');
194
+ next.push('Sessions already open need a restart before they see the server.');
134
195
  const warning = inboundWarning(env);
135
196
  if (warning) next.push(warning);
136
197
  }
@@ -139,6 +200,50 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
139
200
  return { done, next };
140
201
  }
141
202
 
203
+ /** `sidevoice uninstall`: the reverse of install, for this machine. Unregisters the MCP server from Claude
204
+ * Code, stops the connector, removes the installed copies, the skill copy an older version left, and the
205
+ * pairing credential. The room keeps this machine's pairing until it is revoked from the room's page —
206
+ * say so. Codex's machine-wide file is, as always, printed and not touched. */
207
+ export async function uninstall(argv = process.argv.slice(2), env = process.env) {
208
+ const wanted = flag(argv, '--harness');
209
+ const harnesses = wanted ? [wanted] : harnessesPresent(env);
210
+ const done = [], next = [];
211
+ if (harnesses.includes('claude')) {
212
+ const current = claudeRegistration(env);
213
+ if (current?.scope === 'user') {
214
+ try { claude(['mcp', 'remove', '--scope', 'user', 'sidevoice'], env); done.push('Unregistered the MCP server from Claude Code.'); }
215
+ catch (error) { done.push(`Could not unregister from Claude Code (${(error.message || '').split('\n')[0]}). Run:\n claude mcp remove --scope user sidevoice`); }
216
+ } else if (current) {
217
+ next.push(`Claude Code has a sidevoice MCP server registered outside user scope (${current.line}); remove it where it was added.`);
218
+ } else done.push('Claude Code had no sidevoice MCP server registered.');
219
+ if (skillStatus(skillsDir([], env)).state === 'installed') done.push(`Removed the voice-room skill copy at ${removeSkill(skillsDir([], env)).target}.`);
220
+ }
221
+ const running = await runningConnector(env);
222
+ if (running?.pid) {
223
+ try { process.kill(running.pid, 'SIGTERM'); done.push(`Stopped the connector (pid ${running.pid}${running.version ? ', version ' + running.version : ''}).`); }
224
+ catch (error) { next.push(`A connector is running (pid ${running.pid}) and could not be stopped (${error.code || error.message}); stop it yourself.`); }
225
+ }
226
+ if (!fromSource(env) && existsSync(copiesDir(env))) { rmSync(copiesDir(env), { recursive: true, force: true }); done.push(`Removed the installed copies under ${copiesDir(env)}.`); }
227
+ const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
228
+ const paired = pairedRoom(env);
229
+ if (existsSync(dataDir)) {
230
+ rmSync(dataDir, { recursive: true, force: true });
231
+ done.push(`Removed ${dataDir} (credential, socket, outbox, log).`);
232
+ if (paired) next.push(`The room at ${paired.origin} still lists this machine as paired (connector ${paired.connector_id}) until you revoke it from the room's page.`);
233
+ }
234
+ if (harnesses.includes('codex')) next.push(`Remove the [mcp_servers.sidevoice] table from ${env.CODEX_HOME || path.join(os.homedir(), '.codex')}/config.toml — it is machine-wide and this package does not rewrite it.`);
235
+ next.push('Sessions already open keep their MCP server until they end.');
236
+ return { done, next };
237
+ }
238
+
239
+ if (process.env.SIDEVOICE_UNINSTALL_MAIN === '1') {
240
+ try {
241
+ const { done, next } = await uninstall();
242
+ for (const line of done) console.log('· ' + line);
243
+ if (next.length) { console.log('\nLeft for you:'); for (const line of next) console.log('\n' + line); }
244
+ } catch (error) { console.error(error.message); process.exit(1); }
245
+ }
246
+
142
247
  if (process.env.SIDEVOICE_INSTALL_MAIN === '1') {
143
248
  try {
144
249
  const { done, next } = await install();
package/mcp.mjs CHANGED
@@ -10,23 +10,25 @@ 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');
16
19
  const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url));
17
20
 
21
+ // Claude Code keeps at most 2048 characters of these; the rest is cut (measured 2026-09-21).
18
22
  const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
19
- - Call voice_connect only when the user asks to join the voice room or enable voice for this conversation; never as a side effect.
20
- - 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, and ends with a line marked [Sidevoice] that is not the user's words: it asks you to acknowledge by voice first. Treat the header as opaque reply metadata; if the same message_id arrives twice, it is a redelivery: do not act on it again.
21
- - 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.
22
- - 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.
23
- - 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.
24
- - A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
25
- - If the user closes this conversation's voice channel from the room, the connection is removed: voice_say then fails saying so. Continue in writing and do not try to speak again; call voice_connect only when the user asks for voice again.
26
- - voice_status reports whether the room can currently reach this conversation, and which room this machine is paired with.
27
- - Pairing is the user's act, never yours. If voice_connect answers that this machine is not paired with the room (or is paired with a different one), ask the user for the room's address and the one-time pairing code the room shows them under "Emparejar conector" (it expires in ten minutes), then call voice_pair with both and voice_connect again. Never try to obtain a code from the room yourself, and do not offer to: the room only shows it to the person in it.
28
- - On Claude Code, /voice-room is a shortcut for the same joining steps. Read receipts and working state need nothing from you: the room learns them from what the harness records about this conversation.
29
- - 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.`;
23
+ - Call voice_connect only when the user asks to join the room or enable voice; never as a side effect.
24
+ - Voice input is a user message: a JSON header ({"channel":"voice","session_id","revision","message_id"}), the user's literal words, then a [Sidevoice] line that is not the user's. The header is opaque reply metadata. A repeated message_id is a redelivery: do not act on it again.
25
+ - Reply by voice with voice_say, using that message's session_id and revision for every publication. For substantive work: first a short acknowledgement (what you understood, what you will do next), then meaningful checkpoints, then the result. No filler, no narrating tool calls. Publish questions too, and wait.
26
+ - Between steps, at each tool result, take in newly arrived user input before starting the next step: an addition, a refinement or a replacement, by its meaning. Drop obsolete work not yet started; keep what remains useful; say what you now understand. No artificial pauses.
27
+ - "published" means the room stored it, not that the user heard it. If publishing fails, continue in writing.
28
+ - If the user closes this conversation's voice from the room, voice_say fails saying so: continue in writing, do not retry, and call voice_connect again only if asked.
29
+ - Pairing is the user's act. If voice_connect says this machine is not paired with the room, ask the user for the room's address and the one-time code the room shows under "Emparejar conector", then call voice_pair and voice_connect again. Never try to obtain a code from the room yourself.
30
+ - If voice_connect returns inbound.ok false, voice will look sent and never arrive: tell the user inbound.reason, offer inbound.remedy in your own words including the safeguard the machine-wide option removes, and change no settings unasked.
31
+ - Read receipts and working state need nothing from you: the room observes what the harness records.`;
30
32
 
31
33
  // ----- one persistent connection to the connector -----
32
34
  let ipc = null, ipcBuffer = '', ipcSerial = 0;
@@ -80,6 +82,26 @@ const tools = [
80
82
  { name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
81
83
  { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
82
84
  ];
85
+ /** The joining steps as a prompt: what the voice-room skill used to be, now carried by the server itself so
86
+ * nothing is copied into any harness and the steps move with the version. */
87
+ const PROMPTS = [{
88
+ name: 'voice-room',
89
+ description: 'Join the user\'s Sidevoice voice room with this conversation.',
90
+ arguments: [{ name: 'title', description: 'Title for this conversation in the room', required: false }],
91
+ }];
92
+ function promptText(args = {}) {
93
+ const title = (args.title || '').trim();
94
+ return [
95
+ 'Join the voice room for this conversation and keep it reachable.',
96
+ '',
97
+ '1. Call voice_status. If it reports joined and room_reachable, say so in one line and stop.',
98
+ `2. Call voice_connect with the title ${title ? JSON.stringify(title) : 'a short label of what this conversation is about'}. If the user named a room, pass its address as room.`,
99
+ ' If it fails saying this machine is not paired with the room (or is paired with a different one), ask the user for the room\'s address and the one-time code the room shows them under "Emparejar conector"; call voice_pair with both, then voice_connect again. Never try to get a code from the room yourself.',
100
+ '3. Tell the user in one line whether the room can reach this conversation. If inbound.ok is false, relay inbound.reason and offer inbound.remedy in your own words, including what safeguard it removes; change nothing yourself.',
101
+ '',
102
+ 'Nothing else is registered: the room learns that a message was read and whether this conversation is working from what the harness itself records about it. How to behave once joined is in this server\'s instructions.',
103
+ ].join('\n');
104
+ }
83
105
  let binding = null;
84
106
  function originOf(room) {
85
107
  try { return new URL(room).origin; } catch { throw new Error(`"${room}" is not a room address; expected something like https://voice.example`); }
@@ -91,6 +113,11 @@ function pairingNeeded(room, paired) {
91
113
  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
114
  return null;
93
115
  }
116
+ /** A connector from another version serves this conversation with that version's behaviour. */
117
+ function versionNote(connectorVersion) {
118
+ if (!ipc || connectorVersion === VERSION) return {};
119
+ 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.` };
120
+ }
94
121
  function inboundFor(harness, thread) {
95
122
  return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
96
123
  }
@@ -102,7 +129,8 @@ async function invoke(name, args, meta) {
102
129
  if (closed) binding = null;
103
130
  const module = binding ? harnessFor(binding.harness) : null;
104
131
  const inbound = binding ? inboundFor(module, binding.client_ref) : null;
105
- return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null,
132
+ return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null, socket_error: status.socket_error || null,
133
+ version: VERSION, connector_version: status.version || null, ...versionNote(status.version),
106
134
  binding_id: binding?.binding_id || null, harness: binding?.harness || null,
107
135
  capabilities: binding?.capabilities || null, inbound,
108
136
  ...(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 +169,10 @@ async function invoke(name, args, meta) {
141
169
  const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
142
170
  title, delivery: who.delivery, inbound, capabilities, engine });
143
171
  binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
172
+ let connectorVersion = null; try { connectorVersion = (await rpc('status', {})).version || null; } catch {}
144
173
  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 };
174
+ binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound,
175
+ version: VERSION, connector_version: connectorVersion, ...versionNote(connectorVersion) };
146
176
  }
147
177
  if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
148
178
 
@@ -175,7 +205,12 @@ process.stdin.on('data', async chunk => {
175
205
  if (request.id === undefined) continue; // notifications need no answer
176
206
  let result, error;
177
207
  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 };
208
+ if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {}, prompts: {} }, serverInfo: { name: 'sidevoice', version: VERSION }, instructions: INSTRUCTIONS };
209
+ else if (request.method === 'prompts/list') result = { prompts: PROMPTS };
210
+ else if (request.method === 'prompts/get') {
211
+ if (request.params?.name !== 'voice-room') throw Object.assign(new Error('Unknown prompt'), { code: -32602 });
212
+ result = { description: PROMPTS[0].description, messages: [{ role: 'user', content: { type: 'text', text: promptText(request.params?.arguments) } }] };
213
+ }
179
214
  else if (request.method === 'tools/list') result = { tools };
180
215
  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
216
  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.1",
3
+ "version": "0.4.3",
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",
@@ -22,7 +22,6 @@
22
22
  "harness-http.mjs",
23
23
  "pair.mjs",
24
24
  "skill.mjs",
25
- "skill/",
26
25
  "README.md"
27
26
  ],
28
27
  "scripts": {
package/skill.mjs CHANGED
@@ -1,12 +1,10 @@
1
- /** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room
2
- * (`/voice-room`). One file; running install again repairs it. A directory of the same name that is not
3
- * ours is never touched. */
4
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ /** `sidevoice skill remove|status [--dir <skills dir>]`: the voice-room skill is no longer installed — the
2
+ * server carries the same steps as an MCP prompt but copies from earlier versions are still on disk, and
3
+ * this takes ours away. A directory of the same name that is not ours is never touched. */
4
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
7
 
9
- const here = path.dirname(fileURLToPath(import.meta.url));
10
8
  export const SKILL_NAME = 'voice-room';
11
9
  const MARKER = 'sidevoice: installed copy';
12
10
 
@@ -25,17 +23,6 @@ export function status(dir) {
25
23
  return { state: ours ? 'installed' : 'foreign', target };
26
24
  }
27
25
 
28
- export function install(dir) {
29
- const current = status(dir);
30
- if (current.state === 'foreign') throw new Error(`${current.target} already holds a skill that is not Sidevoice's; remove or rename it first.`);
31
- mkdirSync(current.target, { recursive: true });
32
- writeFileSync(path.join(current.target, 'SKILL.md'), readFileSync(path.join(here, 'skill', SKILL_NAME, 'SKILL.md'), 'utf8'));
33
- for (const stale of ['hook.mjs', 'harness-contract.mjs', 'harnesses.mjs', 'harness-claude.mjs', 'harness-codex.mjs', 'harness-http.mjs']) {
34
- rmSync(path.join(current.target, stale), { force: true }); // an older copy carried a hook runtime; it is gone
35
- }
36
- return { ...status(dir), action: current.state === 'installed' ? 'updated' : 'installed' };
37
- }
38
-
39
26
  export function remove(dir) {
40
27
  const current = status(dir);
41
28
  if (current.state === 'foreign') throw new Error(`${current.target} is not Sidevoice's skill; left as it is.`);
@@ -47,11 +34,8 @@ if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
47
34
  const [command] = process.argv.slice(2);
48
35
  const dir = skillsDir();
49
36
  try {
50
- const result = command === 'install' ? install(dir) : command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
51
- if (!result) { console.error('usage: sidevoice skill <install|remove|status> [--dir <skills dir>]'); process.exit(2); }
37
+ const result = command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
38
+ if (!result) { console.error('usage: sidevoice skill <remove|status> [--dir <skills dir>] (the skill is no longer installed: the MCP server offers the voice-room prompt)'); process.exit(2); }
52
39
  console.log(`${result.action || result.state}: ${result.target}`);
53
- if (result.action === 'installed' || result.action === 'updated') {
54
- console.log('In Claude Code, /voice-room joins the room for that conversation. New sessions see the skill; a session already open needs a restart.');
55
- }
56
40
  } catch (error) { console.error(error.message); process.exit(1); }
57
41
  }
@@ -1,16 +0,0 @@
1
- ---
2
- name: voice-room
3
- description: Join the user's Sidevoice voice room with this conversation. Use when the user asks to enable voice, join the room or talk by voice; never as a side effect of other work.
4
- argument-hint: "[title for this conversation in the room]"
5
- metadata:
6
- sidevoice: installed copy; the source is skill/voice-room in @sidevoice/uplink, reinstall with `sidevoice skill install`
7
- ---
8
-
9
- Join the voice room for this conversation and keep it reachable.
10
-
11
- 1. Call `voice_status`. If it reports `joined` and `room_reachable`, say so in one line and stop.
12
- 2. Call `voice_connect` with the title `$ARGUMENTS` when given, otherwise a short label of what this conversation is about. If the user named a room, pass its address as `room`.
13
- If it fails saying this machine is not paired with the room (or is paired with a different one), ask the user for the room's address and the one-time code the room shows them under **Emparejar conector**; call `voice_pair` with both, then `voice_connect` again. Never try to get a code from the room yourself.
14
- 3. Tell the user in one line whether the room can reach this conversation. If `inbound.ok` is false, relay `inbound.reason` and offer `inbound.remedy` in your own words, including what safeguard it removes; change nothing yourself.
15
-
16
- Nothing else is registered: the room learns that a message was read and whether this conversation is working from what Claude Code itself records about the session. How to behave once joined is in the Sidevoice MCP server's own instructions.