@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 +3 -2
- package/cli.mjs +4 -3
- package/connector.mjs +62 -18
- package/harness-contract.mjs +4 -4
- package/install.mjs +117 -12
- package/mcp.mjs +49 -14
- 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,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
|
-
|
|
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 &&
|
|
45
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
147
|
-
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' }));
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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));
|
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
|
@@ -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 {
|
|
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
|
-
/**
|
|
23
|
-
|
|
23
|
+
/** The files that make up this package, copied as they are. */
|
|
24
|
+
const PACKAGE_FILES = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).files.concat('package.json');
|
|
25
|
+
|
|
26
|
+
function fromSource(env) {
|
|
27
|
+
if (env.SIDEVOICE_INSTALL_FROM_SOURCE === '0') return false;
|
|
28
|
+
return env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where installed copies live: one directory per version, under the XDG data home. */
|
|
32
|
+
export function copiesDir(env = process.env) {
|
|
33
|
+
return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'sidevoice');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a harness should run to start the server. From a checkout it names that checkout, so a machine that
|
|
37
|
+
* installed from source keeps working when the published version moves. Otherwise it names a copy of this
|
|
38
|
+
* package that install placed on disk — never `npx`: a session start is not the moment to resolve a package
|
|
39
|
+
* (a cold cache, a bin whose name differs from the package's, a 30 s startup budget; one session found no
|
|
40
|
+
* `sidevoice` binary at all, 2026-09-21). */
|
|
24
41
|
export function serverCommand(env = process.env) {
|
|
25
|
-
const
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
const cli = fromSource(env) ? path.join(here, 'cli.mjs') : path.join(copiesDir(env), VERSION, 'cli.mjs');
|
|
43
|
+
return { command: 'node', args: [cli, 'mcp'] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Put this version's files where serverCommand points, and drop the other versions: an installed copy is
|
|
47
|
+
* disposable and there is one current one. From a checkout nothing is copied. */
|
|
48
|
+
export function materialize(env = process.env) {
|
|
49
|
+
if (fromSource(env)) return { action: 'checkout', target: here };
|
|
50
|
+
const root = copiesDir(env), target = path.join(root, VERSION);
|
|
51
|
+
mkdirSync(target, { recursive: true });
|
|
52
|
+
for (const file of PACKAGE_FILES) {
|
|
53
|
+
const source = path.join(here, file);
|
|
54
|
+
if (existsSync(source)) cpSync(source, path.join(target, file), { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
const removed = [];
|
|
57
|
+
for (const name of readdirSync(root)) {
|
|
58
|
+
if (name !== VERSION) { rmSync(path.join(root, name), { recursive: true, force: true }); removed.push(name); }
|
|
59
|
+
}
|
|
60
|
+
return { action: 'copied', target, removed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The connector that holds this machine's socket, if any, and which version it is: a façade uses whatever
|
|
64
|
+
* connector is running, so one left over from before an upgrade serves every new session with old code. */
|
|
65
|
+
export function runningConnector(env = process.env) {
|
|
66
|
+
const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
67
|
+
const socketPath = env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
68
|
+
let pid = null;
|
|
69
|
+
try { pid = Number(readFileSync(socketPath + '.lock', 'utf8')) || null; } catch { return null; }
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
const socket = net.createConnection(socketPath);
|
|
72
|
+
const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
|
|
73
|
+
const timer = setTimeout(() => done(null), 1500);
|
|
74
|
+
let buffer = '';
|
|
75
|
+
socket.on('error', () => done(null));
|
|
76
|
+
socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method: 'status', params: {} }) + '\n'));
|
|
77
|
+
socket.on('data', chunk => {
|
|
78
|
+
buffer += chunk; const index = buffer.indexOf('\n'); if (index < 0) return;
|
|
79
|
+
try { const reply = JSON.parse(buffer.slice(0, index)); done({ pid, version: reply.result?.version || null, bindings: reply.result?.bindings?.length ?? null }); }
|
|
80
|
+
catch { done({ pid, version: null, bindings: null }); }
|
|
81
|
+
});
|
|
82
|
+
});
|
|
29
83
|
}
|
|
30
84
|
|
|
31
85
|
export function flag(argv, name) {
|
|
@@ -117,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
|
-
|
|
123
|
-
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.`);
|
|
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,
|
|
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
|
|
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
|
|
20
|
-
- Voice input
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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:
|
|
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.
|
|
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.
|