@sidevoice/uplink 0.4.2 → 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,8 +5,9 @@
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';
@@ -22,6 +23,21 @@ const outboxPath = path.join(dataDir, 'outbox.json');
22
23
  const credentialsPath = process.env.SIDEVOICE_CREDENTIALS || path.join(dataDir, 'credentials.json');
23
24
  const idleMs = Number(process.env.SIDEVOICE_CONNECTOR_IDLE_MS || 15_000);
24
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
+ }
25
41
 
26
42
  function credentials() {
27
43
  let saved = {};
@@ -36,7 +52,16 @@ function credentials() {
36
52
  return { url, connector_id, token, room: room.origin };
37
53
  }
38
54
 
39
- 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
+ }
40
65
  function acquireLock() {
41
66
  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
42
67
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -44,8 +69,12 @@ function acquireLock() {
44
69
  catch (error) {
45
70
  if (error.code !== 'EEXIST') throw error;
46
71
  let pid = 0; try { pid = Number(readFileSync(lockPath, 'utf8')); } catch {}
47
- if (pid && alive(pid)) return false; // A live connector holds it: we are redundant.
48
- 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 {}
49
78
  }
50
79
  }
51
80
  return false;
@@ -59,6 +88,7 @@ const closedByRoom = new Map(); // client_ref -> reason: the user closed that
59
88
  const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
60
89
  let outbox = []; // speech frames not yet confirmed by the room
61
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
62
92
  let creds;
63
93
 
64
94
  function loadOutbox() { try { outbox = JSON.parse(readFileSync(outboxPath, 'utf8')); if (!Array.isArray(outbox)) outbox = []; } catch { outbox = []; } }
@@ -114,7 +144,7 @@ function watch(binding) {
114
144
  if (!readReported.has(header.message_id)) {
115
145
  readReported.add(header.message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
116
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 });
117
- 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 || '?'})`);
118
148
  }
119
149
  if (header.channel !== 'voice') return;
120
150
  binding.turn = { turn_id: turn_id || null, session_id: header.session_id, revision: header.revision };
@@ -143,22 +173,25 @@ function open() {
143
173
  send({ type: 'connector.hello', protocol: PROTOCOL, connector_id: creds.connector_id, token: creds.token, host: hostId });
144
174
  });
145
175
  socket.addEventListener('message', event => { receive(JSON.parse(String(event.data))).catch(error => send({ type: 'connector.error', error: error.message })); });
146
- 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(); };
147
177
  // A refused connection surfaces as 'error' with no 'close', and the dead socket stays
148
- // CONNECTING forever: forget it, or open() would never make another one.
149
- socket.addEventListener('close', lost);
150
- 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' }));
151
182
  }
152
183
  function reconnect() {
153
184
  if (closed || reconnectTimer) return;
154
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})`);
155
187
  reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, delay);
156
188
  }
157
189
 
158
190
  async function receive(frame) {
159
191
  switch (frame.type) {
160
192
  case 'connector.welcome':
161
- 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`);
162
195
  for (const binding of bindings.values()) {
163
196
  // `local-*` is only a connector-side placeholder while the first
164
197
  // registration waits for the room to mint its durable binding id.
@@ -176,10 +209,12 @@ async function receive(frame) {
176
209
  const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
177
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); }
178
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 || ''}")`);
179
213
  registering.get(frame.client_ref)?.resolve(frame); return;
180
214
  }
181
- 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;
182
216
  case 'speech.published': {
217
+ log(`speech ${frame.utterance_id || frame.event_id} ${frame.status || 'published'}${frame.reason ? ' (' + frame.reason + ')' : ''}`);
183
218
  outbox = outbox.filter(speech => speech.event_id !== frame.event_id); saveOutbox();
184
219
  publishing.get(frame.event_id)?.resolve(frame); return;
185
220
  }
@@ -198,11 +233,11 @@ async function receive(frame) {
198
233
  try {
199
234
  const harness = harnessFor(binding.harness);
200
235
  const outcome = await harness.deliver(binding.delivery, frame);
201
- 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})`);
202
237
  send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
203
238
  } catch (error) {
204
239
  binding.pending?.delete(frame.message_id);
205
- console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
240
+ log(`delivery of ${frame.event_id} to ${binding.thread} failed: ${error.message}`);
206
241
  send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
207
242
  }
208
243
  });
@@ -214,23 +249,24 @@ async function receive(frame) {
214
249
  if (!binding) return;
215
250
  bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
216
251
  closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
217
- console.error(`[sidevoice] room closed voice for ${binding.thread}`);
252
+ log(`room closed voice for ${binding.thread} (${frame.reason || 'closed_from_room'})`);
218
253
  scheduleExit(); return;
219
254
  }
220
- 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;
221
256
  }
222
257
  }
223
258
 
224
259
  function snapshot() {
225
- return { host: hostId, version: VERSION, 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()],
226
261
  bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
227
262
  ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
228
263
  }
229
264
  function scheduleExit() {
230
265
  if (idleTimer) clearTimeout(idleTimer);
231
- 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);
232
267
  }
233
268
  function shutdown() {
269
+ if (!closed) log(`shutting down (${bindings.size} binding(s), ${clients.size} façade(s))`);
234
270
  closed = true; clearTimeout(reconnectTimer); clearTimeout(idleTimer);
235
271
  try { ws?.close(); } catch {}
236
272
  server.close();
@@ -252,6 +288,7 @@ async function command(client, input) {
252
288
  return { binding_id: existing.binding_id, thread, connected };
253
289
  }
254
290
  const local_id = 'local-' + randomUUID();
291
+ log(`${harness} ${thread} joins ("${title || ''}", delivery ${delivery.kind}, inbound ${inbound ? (inbound.ok ? 'ok' : 'held') : 'n/a'})`);
255
292
  const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
256
293
  bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
257
294
  const frame = await new Promise((resolve, reject) => {
@@ -278,7 +315,7 @@ async function command(client, input) {
278
315
  }
279
316
  case 'unregister': {
280
317
  const binding = bindings.get(params.binding_id);
281
- 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 }); }
282
319
  scheduleExit(); return snapshot();
283
320
  }
284
321
  case 'status': return snapshot();
@@ -289,6 +326,7 @@ async function command(client, input) {
289
326
  function serve(socket) {
290
327
  const client = { socket, bindings: new Set() };
291
328
  clients.add(client); clearTimeout(idleTimer);
329
+ log(`façade attached (${clients.size} now)`);
292
330
  let buffer = '';
293
331
  socket.on('data', chunk => {
294
332
  buffer += chunk;
@@ -305,6 +343,7 @@ function serve(socket) {
305
343
  socket.on('error', () => {});
306
344
  socket.on('close', () => {
307
345
  clients.delete(client);
346
+ log(`façade detached (${clients.size} left); dropping ${client.bindings.size} binding(s)`);
308
347
  // The façade is gone: so is every conversation it spoke for.
309
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 }); }
310
349
  scheduleExit();
@@ -313,7 +352,9 @@ function serve(socket) {
313
352
 
314
353
  creds = credentials();
315
354
  if (!acquireLock()) process.exit(0);
355
+ log(`connector ${VERSION} starting: pid ${process.pid}, host ${hostId}, room ${creds.room}, socket ${socketPath}, log ${logPath}`);
316
356
  loadOutbox();
357
+ if (outbox.length) log(`${outbox.length} speech frame(s) waiting in the outbox`);
317
358
  try { unlinkSync(socketPath); } catch {}
318
359
  const server = net.createServer(serve);
319
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
@@ -16,7 +16,7 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { pairedRoom } from './pair.mjs';
19
- import { install as installSkill, skillsDir } from './skill.mjs';
19
+ import { remove as removeSkill, skillsDir, status as skillStatus } from './skill.mjs';
20
20
 
21
21
  const here = path.dirname(fileURLToPath(import.meta.url));
22
22
  const VERSION = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).version;
@@ -175,8 +175,8 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
175
175
  if (copy.action === 'copied') done.push(`Copied this version to ${copy.target}${copy.removed.length ? ` (removed: ${copy.removed.join(', ')})` : ''}.`);
176
176
  if (harnesses.includes('claude')) {
177
177
  registerWithClaude(done, env);
178
- const outcome = installSkill(skillsDir([], env));
179
- 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.`);
180
180
  }
181
181
 
182
182
  const paired = pairedRoom(env);
@@ -189,9 +189,9 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
189
189
  }
190
190
 
191
191
  if (harnesses.includes('claude')) {
192
- 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).' +
193
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".'));
194
- 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.');
195
195
  const warning = inboundWarning(env);
196
196
  if (warning) next.push(warning);
197
197
  }
@@ -200,6 +200,50 @@ export async function install(argv = process.argv.slice(2), env = process.env) {
200
200
  return { done, next };
201
201
  }
202
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
+
203
247
  if (process.env.SIDEVOICE_INSTALL_MAIN === '1') {
204
248
  try {
205
249
  const { done, next } = await install();
package/mcp.mjs CHANGED
@@ -18,18 +18,17 @@ const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.side
18
18
  const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
19
19
  const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url));
20
20
 
21
+ // Claude Code keeps at most 2048 characters of these; the rest is cut (measured 2026-09-21).
21
22
  const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
22
- - Call voice_connect only when the user asks to join the voice room or enable voice for this conversation; never as a side effect.
23
- - 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.
24
- - 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.
25
- - 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.
26
- - 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.
27
- - A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
28
- - 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.
29
- - voice_status reports whether the room can currently reach this conversation, and which room this machine is paired with.
30
- - 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.
31
- - 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.
32
- - 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.`;
33
32
 
34
33
  // ----- one persistent connection to the connector -----
35
34
  let ipc = null, ipcBuffer = '', ipcSerial = 0;
@@ -83,6 +82,26 @@ const tools = [
83
82
  { name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
84
83
  { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
85
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
+ }
86
105
  let binding = null;
87
106
  function originOf(room) {
88
107
  try { return new URL(room).origin; } catch { throw new Error(`"${room}" is not a room address; expected something like https://voice.example`); }
@@ -110,7 +129,7 @@ async function invoke(name, args, meta) {
110
129
  if (closed) binding = null;
111
130
  const module = binding ? harnessFor(binding.harness) : null;
112
131
  const inbound = binding ? inboundFor(module, binding.client_ref) : null;
113
- 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,
114
133
  version: VERSION, connector_version: status.version || null, ...versionNote(status.version),
115
134
  binding_id: binding?.binding_id || null, harness: binding?.harness || null,
116
135
  capabilities: binding?.capabilities || null, inbound,
@@ -186,7 +205,12 @@ process.stdin.on('data', async chunk => {
186
205
  if (request.id === undefined) continue; // notifications need no answer
187
206
  let result, error;
188
207
  try {
189
- if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'sidevoice', version: VERSION }, 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
+ }
190
214
  else if (request.method === 'tools/list') result = { tools };
191
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) }] }; }
192
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.2",
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.