@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 +3 -2
- package/cli.mjs +4 -3
- package/connector.mjs +59 -18
- package/harness-contract.mjs +4 -4
- package/install.mjs +49 -5
- package/mcp.mjs +37 -13
- package/package.json +1 -2
- package/skill.mjs +6 -22
- package/skill/voice-room/SKILL.md +0 -16
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
|
|
7
|
-
nothing; reports whether the machine is paired and
|
|
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,
|
|
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
|
-
|
|
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 &&
|
|
48
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
150
|
-
socket.addEventListener('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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));
|
package/harness-contract.mjs
CHANGED
|
@@ -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]
|
|
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 {
|
|
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
|
-
|
|
179
|
-
done.push(`
|
|
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,
|
|
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
|
|
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
|
|
23
|
-
- Voice input
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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.
|
|
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
|
|
2
|
-
*
|
|
3
|
-
* ours is never touched. */
|
|
4
|
-
import { existsSync,
|
|
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 === '
|
|
51
|
-
if (!result) { console.error('usage: sidevoice skill <
|
|
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.
|