@sidevoice/uplink 0.2.0 → 0.4.0

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.
@@ -0,0 +1,116 @@
1
+ /** The harness boundary. A known harness declares every capability; callers never infer support
2
+ * from a missing method. Missing or malformed declarations remain unknown, never false. */
3
+
4
+ export const CAPABILITIES = Object.freeze([
5
+ 'deliver',
6
+ 'inspectInbound',
7
+ 'working',
8
+ 'endOfTurn',
9
+ 'sessionIdentity',
10
+ ]);
11
+
12
+ export const SUPPORTED = 'supported';
13
+ export const UNSUPPORTED = 'unsupported';
14
+ export const UNKNOWN = 'unknown';
15
+ const DECLARED_STATES = new Set([SUPPORTED, UNSUPPORTED]);
16
+
17
+ export function capabilityState(harness, capability) {
18
+ const state = harness?.capabilities?.[capability];
19
+ return DECLARED_STATES.has(state) ? state : UNKNOWN;
20
+ }
21
+
22
+ export function advertisedCapabilities(harness) {
23
+ return Object.fromEntries(CAPABILITIES.map(capability => [capability, capabilityState(harness, capability)]));
24
+ }
25
+
26
+ /** `working` and `endOfTurn` are both answered by observation: a harness that supports either
27
+ * implements `observe(thread, handlers)`, which watches what the harness itself writes about that
28
+ * conversation and calls back — `working(bool, { turn_id })` on every transition it can see, and
29
+ * `userMessage({ text, turn_id })` for every user message the conversation admits. It returns a
30
+ * function that stops watching. Nothing is installed in the harness for this to work. */
31
+ export function defineHarness(definition) {
32
+ if (!definition?.name) throw new Error('A harness needs a name');
33
+ for (const capability of CAPABILITIES) {
34
+ const state = definition.capabilities?.[capability];
35
+ if (!DECLARED_STATES.has(state)) throw new Error(`${definition.name} must declare ${capability}`);
36
+ const implemented = capability === 'working' || capability === 'endOfTurn' ? definition.observe : definition[capability];
37
+ if (state === SUPPORTED && typeof implemented !== 'function') {
38
+ throw new Error(`${definition.name} declares ${capability} supported but does not implement it`);
39
+ }
40
+ }
41
+ return Object.freeze({ ...definition, capabilities: Object.freeze({ ...definition.capabilities }) });
42
+ }
43
+
44
+ /** The header before the user's literal words, and — for a voice message — the note after them that
45
+ * asks the conversation to speak first. The note travels inside the message because it is the only
46
+ * thing every harness delivers without anything installed; the instructions name it as not the user's. */
47
+ export function envelope(event) {
48
+ const header = {
49
+ channel: event.channel === 'room-control' ? 'room-control' : 'voice',
50
+ session_id: event.session_id,
51
+ revision: event.revision,
52
+ message_id: event.message_id,
53
+ };
54
+ const note = header.channel === 'voice' ? '\n\n' + nudge(header) : '';
55
+ return JSON.stringify(header) + '\n\n' + event.text + note;
56
+ }
57
+
58
+ /** What the conversation is asked at the moment it reads a voice message. */
59
+ 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.';
63
+ }
64
+
65
+ /** The header at the front of a delivered message, or null when the text is not one of ours. Works on
66
+ * the text as a harness stores it, which may put its own line before the header. */
67
+ export function voiceEnvelope(text) {
68
+ if (typeof text !== 'string') return null;
69
+ const start = text.indexOf('{"channel":');
70
+ if (start < 0) return null;
71
+ const end = text.indexOf('}', start);
72
+ if (end < 0) return null;
73
+ let header;
74
+ try { header = JSON.parse(text.slice(start, end + 1)); } catch { return null; }
75
+ if (!header || (header.channel !== 'voice' && header.channel !== 'room-control') || !header.message_id || !header.session_id || !Number.isInteger(header.revision)) return null;
76
+ return { channel: header.channel, message_id: header.message_id, session_id: header.session_id, revision: header.revision };
77
+ }
78
+
79
+ /** Follows a JSON-lines file the harness appends to: each complete new line is handed to `onLine` as
80
+ * parsed JSON. From where the file is now, unless `catchUp` is set — then what is already there is
81
+ * read first, with `replayed = true`, so a watcher can learn the current state without mistaking old
82
+ * lines for news. The file may not exist yet — `locate` is asked again until it does. */
83
+ export function tailJsonl(locate, onLine, { intervalMs = 400, catchUp = false, caughtUp = () => {} } = {}) {
84
+ let file = null, offset = null, remainder = '', replaying = false;
85
+ const poll = async () => {
86
+ const { statSync, openSync, readSync, closeSync } = await import('node:fs');
87
+ if (!file) { file = locate(); if (!file) return; }
88
+ let size;
89
+ try { size = statSync(file).size; } catch { file = null; offset = null; return; }
90
+ if (offset === null) {
91
+ if (!catchUp) { offset = size; return; } // start at the end: only what happens from now on
92
+ offset = 0; replaying = true;
93
+ }
94
+ if (size < offset) { offset = 0; remainder = ''; } // rewritten: read it again from the top
95
+ if (size === offset) return;
96
+ const fd = openSync(file, 'r');
97
+ try {
98
+ const buffer = Buffer.alloc(size - offset);
99
+ readSync(fd, buffer, 0, buffer.length, offset);
100
+ offset = size;
101
+ remainder += buffer.toString('utf8');
102
+ } finally { closeSync(fd); }
103
+ let index;
104
+ while ((index = remainder.indexOf('\n')) >= 0) {
105
+ const line = remainder.slice(0, index); remainder = remainder.slice(index + 1);
106
+ if (!line.trim()) continue;
107
+ let parsed; try { parsed = JSON.parse(line); } catch { continue; }
108
+ try { onLine(parsed, replaying); } catch {}
109
+ }
110
+ if (replaying) { replaying = false; try { caughtUp(); } catch {} }
111
+ };
112
+ const timer = setInterval(() => { poll().catch(() => {}); }, intervalMs);
113
+ timer.unref?.();
114
+ poll().catch(() => {});
115
+ return () => clearInterval(timer);
116
+ }
@@ -0,0 +1,39 @@
1
+ /** Generic HTTP harness used by explicitly configured external receivers. */
2
+ import { defineHarness, SUPPORTED, UNSUPPORTED } from './harness-contract.mjs';
3
+
4
+ async function deliver(delivery, event) {
5
+ if (delivery?.kind !== 'http') throw new Error(`Unsupported HTTP delivery kind: ${delivery?.kind}`);
6
+ const response = await fetch(delivery.url, {
7
+ method: 'POST',
8
+ headers: { 'content-type': 'application/json' },
9
+ body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
10
+ session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
11
+ signal: AbortSignal.timeout(30_000),
12
+ });
13
+ if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
14
+ return { status: 'accepted', detail: `receiver answered ${response.status}` };
15
+ }
16
+
17
+ function sessionIdentity({ env = process.env } = {}) {
18
+ if (!env.SIDEVOICE_THREAD || !env.SIDEVOICE_DELIVERY_URL) return null;
19
+ return {
20
+ harness: env.SIDEVOICE_HARNESS || 'http',
21
+ thread: env.SIDEVOICE_THREAD,
22
+ delivery: { kind: 'http', url: env.SIDEVOICE_DELIVERY_URL, thread: env.SIDEVOICE_THREAD },
23
+ };
24
+ }
25
+
26
+ export const httpHarness = defineHarness({
27
+ name: 'http',
28
+ capabilities: {
29
+ deliver: SUPPORTED,
30
+ inspectInbound: UNSUPPORTED,
31
+ working: UNSUPPORTED,
32
+ endOfTurn: UNSUPPORTED,
33
+ sessionIdentity: SUPPORTED,
34
+ },
35
+ deliver,
36
+ sessionIdentity,
37
+ });
38
+
39
+ export default httpHarness;
package/harnesses.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /** Registry and selection for the harness modules. The façade and connector ask this registry;
2
+ * neither contains harness-name branches. */
3
+ import { claudeHarness } from './harness-claude.mjs';
4
+ import { codexHarness } from './harness-codex.mjs';
5
+ import { httpHarness } from './harness-http.mjs';
6
+
7
+ export const harnesses = Object.freeze({ claude: claudeHarness, codex: codexHarness, http: httpHarness });
8
+
9
+ export function harnessFor(name) {
10
+ return harnesses[name] || httpHarness;
11
+ }
12
+
13
+ export function identifyHarness(meta, env = process.env) {
14
+ for (const harness of [claudeHarness, codexHarness, httpHarness]) {
15
+ const identity = harness.sessionIdentity({ meta, env });
16
+ if (identity?.delivery) return { ...identity, module: harness };
17
+ }
18
+ throw new Error('Cannot tell which conversation this is: not launched by Claude Code or Codex, and no SIDEVOICE_THREAD/SIDEVOICE_DELIVERY_URL set');
19
+ }
package/install.mjs ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /** `sidevoice install` — put this version of Sidevoice in front of the harnesses on this machine.
3
+ *
4
+ * It registers the MCP server (re-pinned to this version when an older one was registered), installs
5
+ * the skill, says what it changed, and is safe to run twice: run it again after an upgrade and the
6
+ * harness points at the new version. It pairs with nothing. Pairing is a person's act — the room shows
7
+ * a one-time code to whoever is in it, and a conversation asks for it the first time it joins — so
8
+ * the installer only reports whether this machine is paired, and with which room.
9
+ *
10
+ * What it does not do is decide for the person: it never edits a machine-wide Codex configuration it
11
+ * does not own, and it never relaxes Claude Code's inbound safeguard — those it prints, with the reason. */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { pairedRoom } from './pair.mjs';
18
+ import { install as installSkill, skillsDir } from './skill.mjs';
19
+
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const VERSION = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).version;
22
+ /** What a harness should run to start the server. From a checkout it names this copy, so a machine that
23
+ * installed from source keeps working when the published version moves; otherwise the pinned package. */
24
+ export function serverCommand(env = process.env) {
25
+ const fromSource = env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
26
+ return fromSource
27
+ ? { command: 'node', args: [path.join(here, 'cli.mjs'), 'mcp'] }
28
+ : { command: 'npx', args: ['-y', `@sidevoice/uplink@${VERSION}`, 'mcp'] };
29
+ }
30
+
31
+ export function flag(argv, name) {
32
+ const index = argv.indexOf(name);
33
+ return index >= 0 ? argv[index + 1] : undefined;
34
+ }
35
+
36
+ /** Which harnesses this machine has, by what they leave behind. */
37
+ export function harnessesPresent(env = process.env) {
38
+ const found = [];
39
+ if (existsSync(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'))) found.push('claude');
40
+ if (existsSync(env.CODEX_HOME || path.join(os.homedir(), '.codex'))) found.push('codex');
41
+ return found;
42
+ }
43
+
44
+ function claude(args, env) {
45
+ return execFileSync(env.SIDEVOICE_CLAUDE_BIN || 'claude', args, { encoding: 'utf8', timeout: 30_000, env, stdio: ['ignore', 'pipe', 'pipe'] });
46
+ }
47
+
48
+ /** What Claude Code currently runs for `sidevoice`, read from its own `mcp get`: null when nothing is
49
+ * registered or there is no `claude` to ask. The scope matters — only a user-scope entry is ours to move. */
50
+ export function claudeRegistration(env = process.env) {
51
+ let output;
52
+ try { output = claude(['mcp', 'get', 'sidevoice'], env); } catch { return null; }
53
+ const field = name => (output.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, 'm')) || [])[1]?.trim() ?? '';
54
+ const command = field('Command'), args = field('Args');
55
+ if (!command) return null;
56
+ return { scope: /user/i.test(field('Scope')) ? 'user' : 'other', line: [command, args].filter(Boolean).join(' ') };
57
+ }
58
+
59
+ function registerWithClaude(done, env) {
60
+ const { command, args } = serverCommand(env);
61
+ const wanted = [command, ...args].join(' ');
62
+ const manual = `claude mcp add --scope user sidevoice -- ${wanted}`;
63
+ const current = claudeRegistration(env);
64
+ if (current?.line === wanted) { done.push('Claude Code already runs this version of the MCP server.'); return; }
65
+ if (current && current.scope !== 'user') {
66
+ done.push(`Claude Code has a sidevoice MCP server registered outside user scope (${current.line}); not touched. To move it:\n ${manual}`);
67
+ return;
68
+ }
69
+ try {
70
+ if (current) claude(['mcp', 'remove', '--scope', 'user', 'sidevoice'], env);
71
+ claude(['mcp', 'add', '--scope', 'user', 'sidevoice', '--', command, ...args], env);
72
+ done.push(current ? `Re-pointed Claude Code's MCP server to this version (was: ${current.line}).`
73
+ : 'Registered the MCP server with Claude Code (user scope).');
74
+ } catch (error) {
75
+ done.push(`Could not register with Claude Code automatically (${(error.message || '').split('\n')[0]}). Run:\n ${manual}`);
76
+ }
77
+ }
78
+
79
+ /** Codex keeps one machine-wide file that may hold anything its user put there: we never rewrite it. */
80
+ export function codexInstructions(env = process.env) {
81
+ const { command, args } = serverCommand(env);
82
+ return [
83
+ `Add to ${env.CODEX_HOME || path.join(os.homedir(), '.codex')}/config.toml — it is machine-wide and`,
84
+ 'this package does not rewrite it:',
85
+ '',
86
+ ' [mcp_servers.sidevoice]',
87
+ ` command = "${command}"`,
88
+ ` args = [${args.map(a => `"${a}"`).join(', ')}]`,
89
+ '',
90
+ 'Then restart Codex. That is all: read receipts and working state come from what Codex records about the thread.',
91
+ ].join('\n');
92
+ }
93
+
94
+ /** Claude Code holds messages from other local processes when a session bypasses permission prompts. */
95
+ export function inboundWarning(env = process.env) {
96
+ const settings = path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
97
+ let parsed = {};
98
+ try { parsed = JSON.parse(readFileSync(settings, 'utf8')); } catch { return null; }
99
+ if (parsed.crossSessionInbound) return null;
100
+ if (parsed.permissions?.defaultMode !== 'bypassPermissions') return null;
101
+ return [
102
+ 'This machine runs Claude Code sessions in bypassPermissions, and those hold what the room sends',
103
+ 'instead of delivering it — voice looks sent and never arrives. Either start a session with',
104
+ ` --settings '{"crossSessionInbound":"accept"}'`,
105
+ `or add "crossSessionInbound": "accept" to ${settings}. That second one lets any local process post`,
106
+ 'into every Claude session on this machine, which is the safeguard it removes: your call, not ours.',
107
+ ].join('\n');
108
+ }
109
+
110
+ export async function install(argv = process.argv.slice(2), env = process.env) {
111
+ const stray = argv.find(item => !item.startsWith('-') && argv[argv.indexOf(item) - 1] !== '--harness');
112
+ if (stray) throw new Error(`usage: sidevoice install [--harness claude|codex]\n` +
113
+ `Pairing is not part of installing: a conversation asks for the room's code the first time it joins, ` +
114
+ `or run sidevoice pair <room-url> <code> with the code the room shows under "Emparejar conector".`);
115
+ const wanted = flag(argv, '--harness');
116
+ const harnesses = wanted ? [wanted] : harnessesPresent(env);
117
+ const done = [], next = [];
118
+
119
+ done.push(`Sidevoice ${VERSION}.`);
120
+ if (harnesses.includes('claude')) {
121
+ registerWithClaude(done, env);
122
+ const outcome = installSkill(skillsDir([], env));
123
+ done.push(`Skill ${outcome.action} at ${outcome.target}.`);
124
+ }
125
+
126
+ const paired = pairedRoom(env);
127
+ done.push(paired ? `This machine is paired with ${paired.origin} (connector ${paired.connector_id}).`
128
+ : 'This machine is not paired with any room yet.');
129
+
130
+ if (harnesses.includes('claude')) {
131
+ next.push('In a conversation, run /voice-room to join the room.' +
132
+ (paired ? '' : ' The first time, the conversation asks you for the room\'s address and the one-time code the room shows under "Emparejar conector".'));
133
+ next.push('Sessions already open need a restart before they can see the skill.');
134
+ const warning = inboundWarning(env);
135
+ if (warning) next.push(warning);
136
+ }
137
+ if (harnesses.includes('codex')) next.push(codexInstructions(env));
138
+ if (!harnesses.length) next.push('No harness found on this machine. Pass --harness claude or --harness codex.');
139
+ return { done, next };
140
+ }
141
+
142
+ if (process.env.SIDEVOICE_INSTALL_MAIN === '1') {
143
+ try {
144
+ const { done, next } = await install();
145
+ for (const line of done) console.log('· ' + line);
146
+ if (next.length) {
147
+ console.log('\nLeft for you:');
148
+ for (const line of next) console.log('\n' + line);
149
+ }
150
+ } catch (error) { console.error(error.message); process.exit(1); }
151
+ }
package/mcp.mjs CHANGED
@@ -7,7 +7,9 @@ import path from 'node:path';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { inspectInbound } from './harness-claude.mjs';
10
+ import { advertisedCapabilities, capabilityState, SUPPORTED } from './harness-contract.mjs';
11
+ import { harnessFor, identifyHarness } from './harnesses.mjs';
12
+ import { pair, pairedRoom } from './pair.mjs';
11
13
 
12
14
  const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
15
  const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
@@ -15,34 +17,17 @@ const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url))
15
17
 
16
18
  const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
17
19
  - Call voice_connect only when the user asks to join the voice room or enable voice for this conversation; never as a side effect.
18
- - 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. Treat the header as opaque reply metadata; if the same message_id arrives twice, it is a redelivery: do not act on it again.
20
+ - Voice input arrives as a user message that starts with a JSON header ({"channel":"voice","session_id":...,"revision":...,"message_id":...}) followed by the user's literal words, and ends with a line marked [Sidevoice] that is not the user's words: it asks you to acknowledge by voice first. Treat the header as opaque reply metadata; if the same message_id arrives twice, it is a redelivery: do not act on it again.
19
21
  - For substantive work, one incoming voice message may receive multiple voice_say publications: an immediate acknowledgement that states what was understood and the next action, meaningful progress checkpoints while work continues, and a final result. Use the same original session_id and revision for every publication, with distinct utterances; do not manufacture filler or narrate every tool call.
20
22
  - A progress publication is not itself a listening point. Divide substantive execution into bounded steps and, after each tool result or operational boundary, process newly arrived user input before starting the next step. Do not add artificial sleeps or fixed pauses.
21
23
  - If a new user message arrives during active work, treat it as an addition, refinement, or replacement according to its meaning. Stop not-yet-started obsolete work, preserve completed work that remains useful, acknowledge the new interpretation before continuing, and do not later answer a stale request. A tool already running may finish before the correction takes effect; delegation is not a substitute for listening.
22
24
  - A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
23
- - A message with "channel":"room-control" is an instruction from the room (for example: continue in writing); it is not a voice turn to answer aloud.
24
- - voice_status reports whether the room can currently reach this conversation.
25
+ - If the user closes this conversation's voice channel from the room, the connection is removed: voice_say then fails saying so. Continue in writing and do not try to speak again; call voice_connect only when the user asks for voice again.
26
+ - voice_status reports whether the room can currently reach this conversation, and which room this machine is paired with.
27
+ - Pairing is the user's act, never yours. If voice_connect answers that this machine is not paired with the room (or is paired with a different one), ask the user for the room's address and the one-time pairing code the room shows them under "Emparejar conector" (it expires in ten minutes), then call voice_pair with both and voice_connect again. Never try to obtain a code from the room yourself, and do not offer to: the room only shows it to the person in it.
28
+ - On Claude Code, /voice-room is a shortcut for the same joining steps. Read receipts and working state need nothing from you: the room learns them from what the harness records about this conversation.
25
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.`;
26
30
 
27
- /** Who this façade speaks for, decided by what spawned it — never by the model. */
28
- function identity(meta) {
29
- if (process.env.CLAUDE_CODE_SESSION_ID && process.env.CLAUDE_CODE_MESSAGING_SOCKET) {
30
- return { harness: 'claude', thread: process.env.CLAUDE_CODE_SESSION_ID,
31
- delivery: { kind: 'claude-uds', socket: process.env.CLAUDE_CODE_MESSAGING_SOCKET, token: process.env.CLAUDE_CODE_MESSAGING_TOKEN || '' } };
32
- }
33
- let turn = meta?.['x-codex-turn-metadata'] || {};
34
- if (typeof turn === 'string') { try { turn = JSON.parse(turn); } catch { turn = {}; } }
35
- const codexThread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId || meta?.codex_thread_id || turn.thread_id || process.env.CODEX_THREAD_ID;
36
- if (codexThread) {
37
- const delivery = process.env.SIDEVOICE_DELIVERY_URL ? { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: codexThread } : { kind: 'codex-queue', thread: codexThread };
38
- return { harness: 'codex', thread: codexThread, delivery };
39
- }
40
- if (process.env.SIDEVOICE_THREAD && process.env.SIDEVOICE_DELIVERY_URL) {
41
- return { harness: process.env.SIDEVOICE_HARNESS || 'http', thread: process.env.SIDEVOICE_THREAD, delivery: { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: process.env.SIDEVOICE_THREAD } };
42
- }
43
- throw new Error('Cannot tell which conversation this is: not launched by Claude Code or Codex, and no SIDEVOICE_THREAD/SIDEVOICE_DELIVERY_URL set');
44
- }
45
-
46
31
  // ----- one persistent connection to the connector -----
47
32
  let ipc = null, ipcBuffer = '', ipcSerial = 0;
48
33
  const ipcWaiting = new Map();
@@ -86,40 +71,92 @@ async function rpc(method, params) {
86
71
 
87
72
  // ----- tools -----
88
73
  const tools = [
89
- { name: 'voice_connect', description: 'Connect this conversation to the voice room. Only on an explicit request to join or enable voice.',
90
- inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Short label for this conversation in the room' } }, additionalProperties: false } },
74
+ { name: 'voice_connect', description: 'Connect this conversation to the voice room. Only on an explicit request to join or enable voice. Fails, saying what to ask the user, when this machine is not paired with the room.',
75
+ inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Short label for this conversation in the room' }, room: { type: 'string', description: 'The room\'s address (https://…) when the user names one; omitted, the room this machine is paired with' } }, additionalProperties: false } },
76
+ { name: 'voice_pair', description: 'Pair this machine with a room using the one-time code the user read from the room\'s interface ("Emparejar conector"). Only with a code the user gave you; one room per machine, a new pairing replaces the previous one.',
77
+ inputSchema: { type: 'object', properties: { room: { type: 'string', description: 'The room\'s address (https://…)' }, code: { type: 'string', description: 'The one-time pairing code shown by the room' } }, required: ['room', 'code'], additionalProperties: false } },
91
78
  { name: 'voice_say', description: 'Publish a concise spoken version of your reply to the room, with the session_id and revision from the voice message header.',
92
79
  inputSchema: { type: 'object', properties: { text: { type: 'string' }, session_id: { type: 'string' }, revision: { type: 'integer', minimum: 0 }, utterance_id: { type: 'string' }, language: { type: 'string', enum: ['es', 'en', 'fr', 'it', 'pt', 'hi'] } }, required: ['text', 'session_id', 'revision'], additionalProperties: false } },
93
80
  { name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
94
81
  { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
95
82
  ];
96
83
  let binding = null;
84
+ function originOf(room) {
85
+ try { return new URL(room).origin; } catch { throw new Error(`"${room}" is not a room address; expected something like https://voice.example`); }
86
+ }
87
+ /** Ask the user, do not guess: the code exists only on the room's screen. */
88
+ function pairingNeeded(room, paired) {
89
+ const target = room ? originOf(room) : null;
90
+ if (!paired) return `This machine is not paired with ${target ? 'the room at ' + target : 'any room'}. Ask the user for the room's address${target ? ' (confirm ' + target + ')' : ''} and the one-time pairing code the room shows under "Emparejar conector", then call voice_pair with both. Do not fetch a code yourself.`;
91
+ 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
+ return null;
93
+ }
94
+ function inboundFor(harness, thread) {
95
+ return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
96
+ }
97
97
  async function invoke(name, args, meta) {
98
98
  if (name === 'voice_status') {
99
- const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [] };
100
- const inbound = binding?.harness === 'claude' ? inspectInbound(binding.client_ref) : { ok: true };
101
- return { joined: !!binding, room_reachable: status.connected, room_error: status.room_error || null,
102
- binding_id: binding?.binding_id || null, harness: binding?.harness || null, inbound };
99
+ const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [], closed_by_room: [] };
100
+ // The room may have closed this conversation's voice since we joined: the connector is the truth.
101
+ const closed = !!binding && (status.closed_by_room || []).includes(binding.client_ref);
102
+ if (closed) binding = null;
103
+ const module = binding ? harnessFor(binding.harness) : null;
104
+ 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,
106
+ binding_id: binding?.binding_id || null, harness: binding?.harness || null,
107
+ capabilities: binding?.capabilities || null, inbound,
108
+ ...(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.' } : {}) };
109
+ }
110
+ if (name === 'voice_pair') {
111
+ if (!args.room || !args.code) throw new Error('voice_pair needs the room\'s address and the code the user read from it.');
112
+ const previous = pairedRoom();
113
+ // The room shows the code in upper case and compares it that way; a dictated one arrives however it was heard.
114
+ const result = await pair(originOf(args.room), String(args.code).trim().toUpperCase());
115
+ // The connector that is up, if any, was started for the previous credential: let go of it so it can
116
+ // exit, and the next voice_connect starts one for this room. Other conversations still bound to the
117
+ // previous room keep that connector alive until they leave; they are not moved.
118
+ if (binding) { try { await rpc('unregister', { binding_id: binding.binding_id }); } catch {} binding = null; }
119
+ if (ipc) { ipc.end(); ipc = null; }
120
+ return { status: 'paired', room: result.origin, connector_id: result.connector_id,
121
+ ...(previous && previous.origin !== result.origin ? { replaced: previous.origin, note: 'Conversations on this machine still joined to the previous room keep it until they leave.' } : {}),
122
+ next: 'Call voice_connect to join.' };
103
123
  }
104
124
  if (name === 'voice_connect') {
105
- const who = identity(meta);
125
+ const needed = pairingNeeded(args.room, pairedRoom());
126
+ if (needed) { const error = new Error(needed); error.data = { pairing_needed: true, room: args.room ? originOf(args.room) : null }; throw error; }
127
+ const who = identifyHarness(meta);
106
128
  const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
107
129
  // Refuse rather than join a room we cannot hear from: a conversation whose harness holds
108
130
  // what the room posts would sit in the list looking present while the user talks to nobody.
109
- const inbound = who.harness === 'claude' ? inspectInbound(who.thread) : { ok: true };
110
- if (inbound.ok === false) {
131
+ const inbound = inboundFor(who.module, who.thread);
132
+ if (inbound?.ok === false) {
111
133
  const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
112
134
  error.data = { inbound };
113
135
  throw error;
114
136
  }
115
- const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread, title, delivery: who.delivery, inbound });
116
- binding = { ...result, harness: who.harness, client_ref: who.thread };
137
+ const capabilities = advertisedCapabilities(who.module);
138
+ // Which model is answering, read from the session's own launch line rather than asked of the model.
139
+ let engine = null;
140
+ try { engine = who.module.engine?.(who.thread) || null; } catch { engine = null; }
141
+ const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
142
+ title, delivery: who.delivery, inbound, capabilities, engine });
143
+ binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
117
144
  return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
118
- binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, inbound };
145
+ binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound };
119
146
  }
120
147
  if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
148
+
121
149
  if (name === 'voice_say') {
122
- const result = await rpc('publish', { binding_id: binding.binding_id, client_ref: binding.client_ref, text: args.text, session_id: args.session_id, revision: args.revision, utterance_id: args.utterance_id, language: args.language });
150
+ let result;
151
+ try {
152
+ result = await rpc('publish', { binding_id: binding.binding_id, client_ref: binding.client_ref, text: args.text, session_id: args.session_id, revision: args.revision, utterance_id: args.utterance_id, language: args.language });
153
+ } catch (error) {
154
+ if (error.message === 'CLOSED_BY_ROOM') {
155
+ binding = null;
156
+ throw new Error('The user closed this conversation\'s voice channel from the room. Continue in writing and do not publish speech; call voice_connect again only if the user asks for voice.');
157
+ }
158
+ throw error;
159
+ }
123
160
  return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
124
161
  }
125
162
  if (name === 'voice_disconnect') { const result = await rpc('unregister', { binding_id: binding.binding_id }); binding = null; return { status: 'left', room_reachable: result.connected }; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidevoice/uplink",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
@@ -12,11 +12,17 @@
12
12
  },
13
13
  "files": [
14
14
  "cli.mjs",
15
+ "install.mjs",
15
16
  "mcp.mjs",
16
17
  "connector.mjs",
17
- "adapters.mjs",
18
+ "harness-contract.mjs",
19
+ "harnesses.mjs",
18
20
  "harness-claude.mjs",
21
+ "harness-codex.mjs",
22
+ "harness-http.mjs",
19
23
  "pair.mjs",
24
+ "skill.mjs",
25
+ "skill/",
20
26
  "README.md"
21
27
  ],
22
28
  "scripts": {
package/pair.mjs CHANGED
@@ -1,21 +1,53 @@
1
1
  #!/usr/bin/env node
2
- /** One-time pairing: redeem the code shown by the room for this host's connector credential. */
2
+ /** One-time pairing: redeem the code shown by the room for this host's connector credential.
3
+ *
4
+ * The code is the room's to give and the person's to carry: the room shows it to whoever is in it
5
+ * ("Emparejar conector"), and only that person can hand it to this machine. Nothing here asks the
6
+ * room for one — a caller that could would turn "you can reach the address" into "you are in the room". */
3
7
  import os from 'node:os';
4
8
  import path from 'node:path';
5
- import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
10
 
7
- const [room, code] = process.argv.slice(2);
8
- if (!room || !code) { console.error('usage: pair.mjs <room-url> <pairing-code>'); process.exit(2); }
9
- const base = new URL(room);
10
- const response = await fetch(new URL('/api/connectors/pair', base), {
11
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, host: os.hostname() }),
12
- signal: AbortSignal.timeout(15_000),
13
- });
14
- const body = await response.json().catch(() => ({}));
15
- if (!response.ok) { console.error('Pairing failed: ' + (body.detail || response.status)); process.exit(1); }
16
- const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
17
- const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
18
- mkdirSync(dataDir, { recursive: true, mode: 0o700 });
19
- const file = path.join(dataDir, 'credentials.json');
20
- writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
21
- console.log(`Paired with ${base.origin} as connector ${body.connector_id}; credential saved to ${file}`);
11
+ export function dataDir(env = process.env) {
12
+ return env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
+ }
14
+
15
+ /** The room's http(s) origin from the socket address the credential stores. */
16
+ export function roomOrigin(wsUrl) {
17
+ const url = new URL(wsUrl); url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; return url.origin;
18
+ }
19
+
20
+ /** Which room this machine is paired with, or null. */
21
+ export function pairedRoom(env = process.env) {
22
+ try {
23
+ const saved = JSON.parse(readFileSync(path.join(dataDir(env), 'credentials.json'), 'utf8'));
24
+ if (!saved.url || !saved.connector_id || !saved.token) return null;
25
+ return { origin: roomOrigin(saved.url), connector_id: saved.connector_id };
26
+ } catch { return null; }
27
+ }
28
+
29
+ /** Redeem a code for this host's credential. Returns where it was written. */
30
+ export async function pair(room, code, env = process.env) {
31
+ const base = new URL(room);
32
+ const response = await fetch(new URL('/api/connectors/pair', base), {
33
+ method: 'POST', headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ code, host: os.hostname() }), signal: AbortSignal.timeout(15_000),
35
+ });
36
+ const body = await response.json().catch(() => ({}));
37
+ if (!response.ok) throw new Error('Pairing failed: ' + (body.detail || response.status));
38
+ const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
39
+ const directory = dataDir(env);
40
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
41
+ const file = path.join(directory, 'credentials.json');
42
+ writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
43
+ return { file, connector_id: body.connector_id, origin: base.origin };
44
+ }
45
+
46
+ if (process.env.SIDEVOICE_PAIR_MAIN === '1') {
47
+ const [room, code] = process.argv.slice(2);
48
+ if (!room || !code) { console.error('usage: sidevoice pair <room-url> <pairing-code> (the code is shown in the room under "Emparejar conector")'); process.exit(2); }
49
+ try {
50
+ const result = await pair(room, code);
51
+ console.log(`Paired with ${result.origin} as connector ${result.connector_id}; credential saved to ${result.file}`);
52
+ } catch (error) { console.error(error.message); process.exit(1); }
53
+ }
@@ -0,0 +1,16 @@
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.