@sidevoice/uplink 0.3.1 → 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.
- package/README.md +9 -4
- package/cli.mjs +5 -4
- package/connector.mjs +72 -75
- package/harness-claude.mjs +50 -12
- package/harness-codex.mjs +53 -22
- package/harness-contract.mjs +72 -9
- package/harnesses.mjs +0 -17
- package/install.mjs +151 -0
- package/mcp.mjs +37 -6
- package/package.json +2 -2
- package/pair.mjs +49 -17
- package/skill/voice-room/SKILL.md +3 -18
- package/skill.mjs +10 -10
- package/hook.mjs +0 -119
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Sidevoice uplink — the client side (`@sidevoice/uplink`)
|
|
2
2
|
|
|
3
|
-
The client side.
|
|
3
|
+
The client side. One bin (`sidevoice`), these entry points:
|
|
4
4
|
|
|
5
|
+
- `install` — registers the MCP server with the harness (re-pinned to this
|
|
6
|
+
version when an older one was registered) and installs the skill. Pairs with
|
|
7
|
+
nothing; reports whether the machine is paired and with which room.
|
|
5
8
|
- `mcp` — the stdio MCP server a harness starts. One per conversation. Exposes
|
|
6
|
-
`voice_connect`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
|
|
9
|
+
`voice_connect`, `voice_pair`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
|
|
7
10
|
operational instructions in its `initialize` result. It never talks to the
|
|
8
11
|
room: it keeps one local connection to the connector for as long as the
|
|
9
12
|
session lives, and the binding it registered dies with that connection.
|
|
@@ -13,8 +16,10 @@ The client side. Three entry points behind one bin (`sidevoice`):
|
|
|
13
16
|
durable outbox for speech published while offline, answers the room's
|
|
14
17
|
heartbeat, and delivers one input event at a time per binding through the
|
|
15
18
|
adapter that binding was registered with. A file lock makes it a singleton.
|
|
16
|
-
- `pair` — redeems a pairing code from the room UI for this machine's
|
|
17
|
-
credential (`~/.sidevoice/credentials.json`, mode 0600).
|
|
19
|
+
- `pair` — redeems, by hand, a pairing code from the room UI for this machine's
|
|
20
|
+
credential (`~/.sidevoice/credentials.json`, mode 0600). The usual path is the
|
|
21
|
+
conversation's `voice_pair`, with the code the user read from the room; nothing
|
|
22
|
+
on the client side ever asks the room for a code.
|
|
18
23
|
|
|
19
24
|
Harness modules implement one contract (`harness-contract.mjs`): delivery,
|
|
20
25
|
inbound inspection, mechanical working state (polled or lifecycle-backed), end-of-turn reporting and session
|
package/cli.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** `sidevoice <mcp|pair|connector|
|
|
2
|
+
/** `sidevoice <install|mcp|pair|connector|skill> …` — one bin, five entry points. */
|
|
3
3
|
const [, , command] = process.argv;
|
|
4
|
-
const entries = {
|
|
5
|
-
if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector|
|
|
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); }
|
|
6
6
|
process.argv.splice(2, 1);
|
|
7
|
-
if (command === 'hook') process.env.SIDEVOICE_HOOK_MAIN = '1';
|
|
8
7
|
if (command === 'skill') process.env.SIDEVOICE_SKILL_MAIN = '1';
|
|
8
|
+
if (command === 'pair') process.env.SIDEVOICE_PAIR_MAIN = '1';
|
|
9
|
+
if (command === 'install') process.env.SIDEVOICE_INSTALL_MAIN = '1';
|
|
9
10
|
await import(entries[command]);
|
package/connector.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { capabilityState, SUPPORTED,
|
|
10
|
+
import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
|
|
11
11
|
import { harnessFor } from './harnesses.mjs';
|
|
12
12
|
|
|
13
13
|
export const PROTOCOL = 1;
|
|
@@ -25,11 +25,12 @@ function credentials() {
|
|
|
25
25
|
const url = process.env.SIDEVOICE_URL || saved.url;
|
|
26
26
|
const connector_id = process.env.SIDEVOICE_CONNECTOR_ID || saved.connector_id;
|
|
27
27
|
const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
|
|
28
|
-
if (!url || !connector_id || !token) throw new Error(`Not paired
|
|
28
|
+
if (!url || !connector_id || !token) throw new Error(`Not paired with any room (looked in ${credentialsPath}): the conversation's voice_pair, or sidevoice pair <room-url> <code>, with the code the room shows`);
|
|
29
29
|
const parsed = new URL(url);
|
|
30
30
|
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
|
|
31
31
|
if (parsed.protocol !== 'wss:' && !loopback) throw new Error('The room URL must be wss:// unless it is loopback');
|
|
32
|
-
|
|
32
|
+
const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
|
|
33
|
+
return { url, connector_id, token, room: room.origin };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
|
|
@@ -52,7 +53,7 @@ const registering = new Map(); // client_ref -> { resolve, reject, timer }
|
|
|
52
53
|
const publishing = new Map(); // event_id -> { resolve, timer }
|
|
53
54
|
const clients = new Set(); // façade IPC connections
|
|
54
55
|
const closedByRoom = new Map(); // client_ref -> reason: the user closed that conversation's voice from the room
|
|
55
|
-
const readReported = new Set(); // message ids already reported as read, so a
|
|
56
|
+
const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
|
|
56
57
|
let outbox = []; // speech frames not yet confirmed by the room
|
|
57
58
|
let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
|
|
58
59
|
let creds;
|
|
@@ -64,43 +65,73 @@ function saveOutbox() {
|
|
|
64
65
|
}
|
|
65
66
|
function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
|
|
66
67
|
|
|
67
|
-
/* Whether a conversation is working
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
/* Whether a conversation is working, and whether it has read what the room sent, are the harness's own
|
|
69
|
+
* state — and every harness writes that state down somewhere of its own: Claude Code in a session registry
|
|
70
|
+
* and a transcript, Codex in the thread's rollout. Each module watches what its harness writes (`observe`)
|
|
71
|
+
* and calls back; nothing is installed in the harness for it. Unknown or unsupported is skipped rather
|
|
72
|
+
* than rendered as false.
|
|
70
73
|
*
|
|
71
74
|
* The connector does not assume the room still knows what it was told. A transition is sent the moment it
|
|
72
|
-
*
|
|
75
|
+
* is seen, and the same state is said again every few seconds regardless: a room that restarted, or a
|
|
73
76
|
* socket that dropped, learns what is true within one interval instead of waiting for the next change that
|
|
74
77
|
* may never come (a conversation that was already working when the room came back showed nothing at all,
|
|
75
78
|
* 2026-09-20). Saying it again is one small frame; not saying it is a light that never comes on. */
|
|
76
|
-
const WORK_POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
|
|
77
79
|
const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
|
|
78
|
-
|
|
80
|
+
const PENDING_MAX = 64;
|
|
81
|
+
let announceTimer = null;
|
|
79
82
|
function announceWork(binding, working, extra = {}) {
|
|
80
83
|
binding.working = working;
|
|
81
84
|
binding.workingSentAt = Date.now();
|
|
82
85
|
return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
|
|
83
86
|
}
|
|
84
|
-
function
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
if (!bindings.size) { clearInterval(
|
|
87
|
+
function keepAnnouncing() {
|
|
88
|
+
if (announceTimer) return;
|
|
89
|
+
announceTimer = setInterval(() => {
|
|
90
|
+
if (!bindings.size) { clearInterval(announceTimer); announceTimer = null; return; }
|
|
88
91
|
for (const binding of bindings.values()) {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
let working = binding.working;
|
|
92
|
-
if (harness.workingSource === WORKING_POLL) {
|
|
93
|
-
const polled = harness.working(binding.client_ref);
|
|
94
|
-
if (polled === null) continue;
|
|
95
|
-
working = polled;
|
|
96
|
-
} else if (typeof working !== 'boolean') continue;
|
|
97
|
-
const due = !binding.workingSentAt || Date.now() - binding.workingSentAt >= WORK_ANNOUNCE_MS;
|
|
98
|
-
if (working === binding.working && !due) continue;
|
|
99
|
-
announceWork(binding, working);
|
|
92
|
+
if (typeof binding.working !== 'boolean') continue;
|
|
93
|
+
if (Date.now() - (binding.workingSentAt || 0) >= WORK_ANNOUNCE_MS) announceWork(binding, binding.working);
|
|
100
94
|
}
|
|
101
|
-
},
|
|
102
|
-
|
|
95
|
+
}, Math.max(50, WORK_ANNOUNCE_MS / 4));
|
|
96
|
+
announceTimer.unref?.();
|
|
103
97
|
}
|
|
98
|
+
/** Start watching a binding's conversation through its harness. What we delivered and it has not yet taken
|
|
99
|
+
* waits in `pending`; the moment its transcript shows the message, the room gets the second tick and a
|
|
100
|
+
* correlated start of turn; the end of that turn carries the same correlation. */
|
|
101
|
+
function watch(binding) {
|
|
102
|
+
const harness = harnessFor(binding.harness);
|
|
103
|
+
if (binding.stop || capabilityState(harness, 'working') !== SUPPORTED || typeof harness.observe !== 'function') return;
|
|
104
|
+
binding.pending ||= new Map();
|
|
105
|
+
const correlation = () => binding.turn ? { turn_id: binding.turn.turn_id, session_id: binding.turn.session_id, revision: binding.turn.revision } : {};
|
|
106
|
+
binding.stop = harness.observe(binding.thread, {
|
|
107
|
+
userMessage({ text, turn_id }) {
|
|
108
|
+
const header = voiceEnvelope(text);
|
|
109
|
+
if (!header || !binding.pending.has(header.message_id)) return;
|
|
110
|
+
binding.pending.delete(header.message_id);
|
|
111
|
+
if (!readReported.has(header.message_id)) {
|
|
112
|
+
readReported.add(header.message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
|
|
113
|
+
send({ type: 'input.read', binding_id: binding.binding_id, message_id: header.message_id, session_id: header.session_id, revision: header.revision, turn_id: turn_id || null });
|
|
114
|
+
console.error(`[sidevoice] ${binding.thread} read ${header.message_id}`);
|
|
115
|
+
}
|
|
116
|
+
if (header.channel !== 'voice') return;
|
|
117
|
+
binding.turn = { turn_id: turn_id || null, session_id: header.session_id, revision: header.revision };
|
|
118
|
+
announceWork(binding, true, turn_id ? { turn_phase: 'start', ...correlation() } : {});
|
|
119
|
+
},
|
|
120
|
+
working(working, { turn_id } = {}) {
|
|
121
|
+
if (working) {
|
|
122
|
+
// A start we can name is said with its name; the correlated start, if any, comes with the message itself.
|
|
123
|
+
if (binding.turn && turn_id && binding.turn.turn_id === turn_id) return announceWork(binding, true, { turn_phase: 'start', ...correlation() });
|
|
124
|
+
return announceWork(binding, true, {});
|
|
125
|
+
}
|
|
126
|
+
const ours = binding.turn && (!turn_id || !binding.turn.turn_id || binding.turn.turn_id === turn_id);
|
|
127
|
+
if (ours) { const extra = binding.turn.turn_id ? { turn_phase: 'end', ...correlation() } : {}; binding.turn = null; return announceWork(binding, false, extra); }
|
|
128
|
+
if (turn_id && binding.turn) return; // some other turn ended: ours is still running
|
|
129
|
+
return announceWork(binding, false, {});
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
keepAnnouncing();
|
|
133
|
+
}
|
|
134
|
+
function unwatch(binding) { try { binding.stop?.(); } catch {} binding.stop = null; }
|
|
104
135
|
|
|
105
136
|
function open() {
|
|
106
137
|
if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
@@ -154,12 +185,20 @@ async function receive(frame) {
|
|
|
154
185
|
if (!binding) { send({ type: 'input.ack', event_id: frame.event_id, status: 'unknown_binding' }); return; }
|
|
155
186
|
// One delivery at a time per binding keeps the user's turns in order.
|
|
156
187
|
binding.chain = (binding.chain || Promise.resolve()).then(async () => {
|
|
188
|
+
// Expected before it is sent: the harness can take the message, and its transcript show it, before the
|
|
189
|
+
// delivery call has even settled (Claude Code admitted one 9 ms after the write; the socket answered
|
|
190
|
+
// 1.5 s later, 2026-09-21). A message expected and never taken costs a map entry.
|
|
191
|
+
if (binding.pending && frame.message_id) {
|
|
192
|
+
binding.pending.set(frame.message_id, { session_id: frame.session_id, revision: frame.revision, at: Date.now() });
|
|
193
|
+
while (binding.pending.size > PENDING_MAX) binding.pending.delete(binding.pending.keys().next().value);
|
|
194
|
+
}
|
|
157
195
|
try {
|
|
158
196
|
const harness = harnessFor(binding.harness);
|
|
159
197
|
const outcome = await harness.deliver(binding.delivery, frame);
|
|
160
198
|
console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
|
|
161
199
|
send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
|
|
162
200
|
} catch (error) {
|
|
201
|
+
binding.pending?.delete(frame.message_id);
|
|
163
202
|
console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
|
|
164
203
|
send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
|
|
165
204
|
}
|
|
@@ -170,7 +209,7 @@ async function receive(frame) {
|
|
|
170
209
|
// The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
|
|
171
210
|
const binding = bindings.get(frame.binding_id);
|
|
172
211
|
if (!binding) return;
|
|
173
|
-
bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding);
|
|
212
|
+
bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
|
|
174
213
|
closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
|
|
175
214
|
console.error(`[sidevoice] room closed voice for ${binding.thread}`);
|
|
176
215
|
scheduleExit(); return;
|
|
@@ -180,7 +219,7 @@ async function receive(frame) {
|
|
|
180
219
|
}
|
|
181
220
|
|
|
182
221
|
function snapshot() {
|
|
183
|
-
return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
222
|
+
return { host: hostId, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
184
223
|
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
|
|
185
224
|
({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
|
|
186
225
|
}
|
|
@@ -211,12 +250,12 @@ async function command(client, input) {
|
|
|
211
250
|
}
|
|
212
251
|
const local_id = 'local-' + randomUUID();
|
|
213
252
|
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
|
|
214
|
-
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open();
|
|
253
|
+
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
|
|
215
254
|
const frame = await new Promise((resolve, reject) => {
|
|
216
255
|
const timer = setTimeout(() => { registering.delete(client_ref); reject(new Error(connected ? 'The room did not confirm the binding' : 'The room is unreachable; retrying in the background')); }, 10_000);
|
|
217
256
|
registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
|
|
218
257
|
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
|
|
219
|
-
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); throw error; });
|
|
258
|
+
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); unwatch(binding); throw error; });
|
|
220
259
|
return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
|
|
221
260
|
}
|
|
222
261
|
case 'publish': {
|
|
@@ -234,51 +273,9 @@ async function command(client, input) {
|
|
|
234
273
|
const { type, event_id, ...result } = reply;
|
|
235
274
|
return result;
|
|
236
275
|
}
|
|
237
|
-
case 'read': {
|
|
238
|
-
// A harness hook says the conversation admitted this voice message: the room learns it was read.
|
|
239
|
-
const { thread, message_id, session_id, revision, turn_id } = params;
|
|
240
|
-
if (!thread || !message_id) throw new Error('thread and message_id are required');
|
|
241
|
-
const binding = [...bindings.values()].find(b => b.thread === thread || b.client_ref === thread);
|
|
242
|
-
if (!binding) return { status: 'no_binding' };
|
|
243
|
-
if (readReported.has(message_id)) return { status: 'already_reported' };
|
|
244
|
-
readReported.add(message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
|
|
245
|
-
const sent = send({ type: 'input.read', binding_id: binding.binding_id, message_id, session_id, revision, turn_id: turn_id || null });
|
|
246
|
-
return { status: sent ? 'sent' : 'offline' };
|
|
247
|
-
}
|
|
248
|
-
case 'turn_end': {
|
|
249
|
-
const binding = [...bindings.values()].find(b => b.thread === params.thread || b.client_ref === params.thread);
|
|
250
|
-
if (!binding) return { status: 'no_binding' };
|
|
251
|
-
const sent = announceWork(binding, false, { turn_id: params.turn_id || null });
|
|
252
|
-
return { status: sent ? 'sent' : 'offline' };
|
|
253
|
-
}
|
|
254
|
-
case 'working': {
|
|
255
|
-
const { thread, turn_id, working } = params;
|
|
256
|
-
if (!thread || typeof working !== 'boolean' || !turn_id) throw new Error('thread, turn_id and working are required');
|
|
257
|
-
const binding = [...bindings.values()].find(b => b.thread === thread || b.client_ref === thread);
|
|
258
|
-
if (!binding) return { status: 'no_binding' };
|
|
259
|
-
binding.activeTurns ||= new Map();
|
|
260
|
-
binding.completedTurns ||= new Set();
|
|
261
|
-
if (working) {
|
|
262
|
-
if (binding.completedTurns.has(turn_id)) return { status: 'stale' };
|
|
263
|
-
if (binding.activeTurns.has(turn_id)) return { status: 'already_reported' };
|
|
264
|
-
const correlation = { turn_id,
|
|
265
|
-
...(typeof params.session_id === 'string' ? { session_id: params.session_id } : {}),
|
|
266
|
-
...(Number.isInteger(params.revision) ? { revision: params.revision } : {}) };
|
|
267
|
-
binding.activeTurns.set(turn_id, correlation);
|
|
268
|
-
const sent = announceWork(binding, true, { turn_phase: 'start', ...correlation });
|
|
269
|
-
return { status: sent ? 'sent' : 'offline' };
|
|
270
|
-
}
|
|
271
|
-
if (binding.completedTurns.has(turn_id)) return { status: 'already_reported' };
|
|
272
|
-
const correlation = binding.activeTurns.get(turn_id);
|
|
273
|
-
binding.completedTurns.add(turn_id);
|
|
274
|
-
if (!correlation) return { status: 'stale' };
|
|
275
|
-
binding.activeTurns.delete(turn_id);
|
|
276
|
-
const sent = announceWork(binding, binding.activeTurns.size > 0, { turn_phase: 'end', ...correlation });
|
|
277
|
-
return { status: sent ? 'sent' : 'offline' };
|
|
278
|
-
}
|
|
279
276
|
case 'unregister': {
|
|
280
277
|
const binding = bindings.get(params.binding_id);
|
|
281
|
-
if (binding) { bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.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 }); }
|
|
282
279
|
scheduleExit(); return snapshot();
|
|
283
280
|
}
|
|
284
281
|
case 'status': return snapshot();
|
|
@@ -306,7 +303,7 @@ function serve(socket) {
|
|
|
306
303
|
socket.on('close', () => {
|
|
307
304
|
clients.delete(client);
|
|
308
305
|
// The façade is gone: so is every conversation it spoke for.
|
|
309
|
-
for (const binding of client.bindings) { bindings.delete(binding.binding_id); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
|
|
306
|
+
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
307
|
scheduleExit();
|
|
311
308
|
});
|
|
312
309
|
}
|
package/harness-claude.mjs
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* cannot read could tighten this further, and the result says so rather than pretending.
|
|
8
8
|
* Documented at https://code.claude.com/docs/en/cross-session-messaging */
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import net from 'node:net';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { defineHarness, envelope, SUPPORTED,
|
|
14
|
+
import { defineHarness, envelope, SUPPORTED, tailJsonl } from './harness-contract.mjs';
|
|
15
15
|
|
|
16
16
|
const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
17
17
|
|
|
@@ -135,8 +135,8 @@ export function sessionIdentity({ env = process.env } = {}) {
|
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
/** Claude Code's session inbox sends no acknowledgement.
|
|
139
|
-
*
|
|
138
|
+
/** Claude Code's session inbox sends no acknowledgement. The read receipt comes from watching the
|
|
139
|
+
* session's own transcript (see observe); this method reports only what the socket itself proves. */
|
|
140
140
|
export function deliver(delivery, event) {
|
|
141
141
|
if (delivery?.kind !== 'claude-uds') throw new Error(`Unsupported Claude delivery kind: ${delivery?.kind}`);
|
|
142
142
|
return new Promise((resolve, reject) => {
|
|
@@ -161,16 +161,55 @@ export function deliver(delivery, event) {
|
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
/** The transcript Claude Code writes for a session: one JSON-lines file named after the session id, under
|
|
165
|
+
* the project directory it derives from the launch cwd. Found by name rather than derived, so a renamed
|
|
166
|
+
* or moved cwd changes nothing. */
|
|
167
|
+
export function transcriptPath(sessionId) {
|
|
168
|
+
const projects = path.join(configDir(), 'projects');
|
|
169
|
+
let dirs = [];
|
|
170
|
+
try { dirs = readdirSync(projects); } catch { return null; }
|
|
171
|
+
for (const dir of dirs) {
|
|
172
|
+
const candidate = path.join(projects, dir, sessionId + '.jsonl');
|
|
173
|
+
if (existsSync(candidate)) return candidate;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The text of a transcript entry that is a user message, or null for anything else (tool results,
|
|
179
|
+
* attachments, the session's own bookkeeping). */
|
|
180
|
+
export function userMessageText(entry) {
|
|
181
|
+
if (entry?.type !== 'user' || entry.message?.role !== 'user') return null;
|
|
182
|
+
const content = entry.message.content;
|
|
183
|
+
if (typeof content === 'string') return content;
|
|
184
|
+
if (!Array.isArray(content)) return null;
|
|
185
|
+
const parts = content.filter(part => part?.type === 'text').map(part => part.text);
|
|
186
|
+
return parts.length ? parts.join('\n') : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
|
|
190
|
+
|
|
191
|
+
/** Watch one session through what Claude Code itself writes about it, and nothing installed in it:
|
|
192
|
+
* its registry record says whether it is busy, and its transcript records every user message the
|
|
193
|
+
* moment the session admits it (a message from the inbox is appended as the turn takes it, not when
|
|
194
|
+
* the socket accepted it — the difference is what the second tick shows). */
|
|
195
|
+
export function observe(sessionId, handlers) {
|
|
196
|
+
let lastStatus = null;
|
|
197
|
+
const stopTranscript = tailJsonl(() => transcriptPath(sessionId), entry => {
|
|
198
|
+
const text = userMessageText(entry);
|
|
199
|
+
if (text !== null) handlers.userMessage({ text, turn_id: entry.promptId || null });
|
|
200
|
+
}, { intervalMs: POLL_MS });
|
|
201
|
+
const timer = setInterval(() => {
|
|
202
|
+
const working = sessionWorking(sessionId);
|
|
203
|
+
if (working === null || working === lastStatus) return;
|
|
204
|
+
lastStatus = working;
|
|
205
|
+
handlers.working(working, {});
|
|
206
|
+
}, POLL_MS);
|
|
207
|
+
timer.unref?.();
|
|
208
|
+
return () => { clearInterval(timer); stopTranscript(); };
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
export const claudeHarness = defineHarness({
|
|
172
212
|
name: 'claude',
|
|
173
|
-
workingSource: WORKING_POLL,
|
|
174
213
|
capabilities: {
|
|
175
214
|
deliver: SUPPORTED,
|
|
176
215
|
inspectInbound: SUPPORTED,
|
|
@@ -181,8 +220,7 @@ export const claudeHarness = defineHarness({
|
|
|
181
220
|
deliver,
|
|
182
221
|
inspectInbound,
|
|
183
222
|
engine: sessionEngine,
|
|
184
|
-
|
|
185
|
-
endOfTurn,
|
|
223
|
+
observe,
|
|
186
224
|
sessionIdentity,
|
|
187
225
|
});
|
|
188
226
|
|
package/harness-codex.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** Codex harness implementation.
|
|
2
2
|
*
|
|
3
|
-
* Delivery and identity are available to the stdio MCP façade.
|
|
4
|
-
*
|
|
3
|
+
* Delivery and identity are available to the stdio MCP façade. Working state and read receipts come
|
|
4
|
+
* from the thread's own rollout file, which Codex appends as the turn runs; nothing is configured in Codex. */
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { defineHarness, envelope, SUPPORTED, UNSUPPORTED, tailJsonl } from './harness-contract.mjs';
|
|
7
10
|
|
|
8
11
|
function turnMetadata(meta) {
|
|
9
12
|
let turn = meta?.['x-codex-turn-metadata'] || {};
|
|
@@ -55,29 +58,58 @@ function engine(thread, env = process.env) {
|
|
|
55
58
|
return named ? { model: named, effort: env.CODEX_REASONING_EFFORT || null, thinking: null } : null;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
62
|
+
|
|
63
|
+
/** The rollout Codex writes for a thread: `sessions/YYYY/MM/DD/rollout-<stamp>-<thread id>.jsonl`. Found by
|
|
64
|
+
* its name, newest day first, so nothing has to be asked of Codex's database. */
|
|
65
|
+
export function rolloutPath(threadId) {
|
|
66
|
+
const root = path.join(codexHome(), 'sessions');
|
|
67
|
+
const list = dir => { try { return readdirSync(dir).sort().reverse(); } catch { return []; } };
|
|
68
|
+
for (const year of list(root)) for (const month of list(path.join(root, year))) for (const day of list(path.join(root, year, month))) {
|
|
69
|
+
const dir = path.join(root, year, month, day);
|
|
70
|
+
const file = list(dir).find(name => name.endsWith('-' + threadId + '.jsonl'));
|
|
71
|
+
if (file) return path.join(dir, file);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
/**
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
/** What one rollout line says, in the terms of the contract: a working transition, a user message, or nothing. */
|
|
77
|
+
export function interpretRollout(entry, state = {}) {
|
|
78
|
+
const payload = entry?.payload || {};
|
|
79
|
+
if (entry?.type === 'event_msg') {
|
|
80
|
+
if (payload.type === 'task_started' && payload.turn_id) { state.turn_id = payload.turn_id; return { working: true, turn_id: payload.turn_id }; }
|
|
81
|
+
if ((payload.type === 'task_complete' || payload.type === 'turn_aborted') && payload.turn_id) {
|
|
82
|
+
if (state.turn_id === payload.turn_id) state.turn_id = null;
|
|
83
|
+
return { working: false, turn_id: payload.turn_id };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (entry?.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
|
|
88
|
+
const text = (payload.content || []).filter(part => part?.type === 'input_text').map(part => part.text).join('\n');
|
|
89
|
+
return text ? { text, turn_id: state.turn_id || null } : null;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
|
|
95
|
+
|
|
96
|
+
/** Watch one thread through its rollout: task_started/task_complete are the turn, a user message is the
|
|
97
|
+
* moment the thread took it (a queued message is written when the turn starts on it). What the rollout
|
|
98
|
+
* already holds is read first, silently, so a turn that was running before we looked is reported as
|
|
99
|
+
* running — old messages are not re-read. */
|
|
100
|
+
export function observe(threadId, handlers) {
|
|
101
|
+
const state = {};
|
|
102
|
+
let working = null;
|
|
103
|
+
return tailJsonl(() => rolloutPath(threadId), (entry, replayed) => {
|
|
104
|
+
const seen = interpretRollout(entry, state);
|
|
105
|
+
if (!seen) return;
|
|
106
|
+
if (typeof seen.working === 'boolean') { working = seen.working; if (!replayed) handlers.working(seen.working, { turn_id: seen.turn_id }); }
|
|
107
|
+
else if (!replayed) handlers.userMessage({ text: seen.text, turn_id: seen.turn_id });
|
|
108
|
+
}, { intervalMs: POLL_MS, catchUp: true, caughtUp: () => { if (working !== null) handlers.working(working, { turn_id: working ? state.turn_id : null }); } });
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
export const codexHarness = defineHarness({
|
|
79
112
|
name: 'codex',
|
|
80
|
-
workingSource: WORKING_EVENT,
|
|
81
113
|
capabilities: {
|
|
82
114
|
deliver: SUPPORTED,
|
|
83
115
|
inspectInbound: UNSUPPORTED,
|
|
@@ -87,8 +119,7 @@ export const codexHarness = defineHarness({
|
|
|
87
119
|
},
|
|
88
120
|
deliver,
|
|
89
121
|
engine,
|
|
90
|
-
|
|
91
|
-
endOfTurn,
|
|
122
|
+
observe,
|
|
92
123
|
sessionIdentity,
|
|
93
124
|
});
|
|
94
125
|
|
package/harness-contract.mjs
CHANGED
|
@@ -12,10 +12,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
12
12
|
export const SUPPORTED = 'supported';
|
|
13
13
|
export const UNSUPPORTED = 'unsupported';
|
|
14
14
|
export const UNKNOWN = 'unknown';
|
|
15
|
-
export const WORKING_POLL = 'poll';
|
|
16
|
-
export const WORKING_EVENT = 'event';
|
|
17
15
|
const DECLARED_STATES = new Set([SUPPORTED, UNSUPPORTED]);
|
|
18
|
-
const WORKING_SOURCES = new Set([WORKING_POLL, WORKING_EVENT]);
|
|
19
16
|
|
|
20
17
|
export function capabilityState(harness, capability) {
|
|
21
18
|
const state = harness?.capabilities?.[capability];
|
|
@@ -26,22 +23,27 @@ export function advertisedCapabilities(harness) {
|
|
|
26
23
|
return Object.fromEntries(CAPABILITIES.map(capability => [capability, capabilityState(harness, capability)]));
|
|
27
24
|
}
|
|
28
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. */
|
|
29
31
|
export function defineHarness(definition) {
|
|
30
32
|
if (!definition?.name) throw new Error('A harness needs a name');
|
|
31
33
|
for (const capability of CAPABILITIES) {
|
|
32
34
|
const state = definition.capabilities?.[capability];
|
|
33
35
|
if (!DECLARED_STATES.has(state)) throw new Error(`${definition.name} must declare ${capability}`);
|
|
34
|
-
|
|
36
|
+
const implemented = capability === 'working' || capability === 'endOfTurn' ? definition.observe : definition[capability];
|
|
37
|
+
if (state === SUPPORTED && typeof implemented !== 'function') {
|
|
35
38
|
throw new Error(`${definition.name} declares ${capability} supported but does not implement it`);
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
|
-
if (definition.capabilities.working === SUPPORTED && !WORKING_SOURCES.has(definition.workingSource)) {
|
|
39
|
-
throw new Error(`${definition.name} declares working supported but does not declare a working source`);
|
|
40
|
-
}
|
|
41
41
|
return Object.freeze({ ...definition, capabilities: Object.freeze({ ...definition.capabilities }) });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** The header
|
|
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. */
|
|
45
47
|
export function envelope(event) {
|
|
46
48
|
const header = {
|
|
47
49
|
channel: event.channel === 'room-control' ? 'room-control' : 'voice',
|
|
@@ -49,5 +51,66 @@ export function envelope(event) {
|
|
|
49
51
|
revision: event.revision,
|
|
50
52
|
message_id: event.message_id,
|
|
51
53
|
};
|
|
52
|
-
|
|
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);
|
|
53
116
|
}
|
package/harnesses.mjs
CHANGED
|
@@ -17,20 +17,3 @@ export function identifyHarness(meta, env = process.env) {
|
|
|
17
17
|
}
|
|
18
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
19
|
}
|
|
20
|
-
|
|
21
|
-
/** Which harness a hook invocation belongs to.
|
|
22
|
-
*
|
|
23
|
-
* The installed hook command names it (`sidevoice hook --harness <name>`, or SIDEVOICE_HOOK_HARNESS):
|
|
24
|
-
* a harness started from another harness's terminal inherits that one's environment variables, and both
|
|
25
|
-
* hook payloads carry a `session_id`, so neither the environment nor the payload can tell them apart on
|
|
26
|
-
* its own. Naming it at the point of install is mechanical; guessing is not. Without a declaration the
|
|
27
|
-
* modules are asked in order, which only holds when a single harness runs on the machine. */
|
|
28
|
-
export function identifyHookHarness(payload, env = process.env) {
|
|
29
|
-
const declared = env.SIDEVOICE_HOOK_HARNESS;
|
|
30
|
-
const candidates = declared ? [harnesses[declared]].filter(Boolean) : [claudeHarness, codexHarness, httpHarness];
|
|
31
|
-
for (const harness of candidates) {
|
|
32
|
-
const identity = harness.sessionIdentity({ payload, env });
|
|
33
|
-
if (identity) return { ...identity, module: harness };
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
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
|
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { advertisedCapabilities, capabilityState, SUPPORTED } from './harness-contract.mjs';
|
|
11
11
|
import { harnessFor, identifyHarness } from './harnesses.mjs';
|
|
12
|
+
import { pair, pairedRoom } from './pair.mjs';
|
|
12
13
|
|
|
13
14
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
14
15
|
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
@@ -16,14 +17,15 @@ const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url))
|
|
|
16
17
|
|
|
17
18
|
const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
|
|
18
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.
|
|
19
|
-
- 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.
|
|
20
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.
|
|
21
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.
|
|
22
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.
|
|
23
24
|
- A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
|
|
24
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.
|
|
25
|
-
- voice_status reports whether the room can currently reach this conversation.
|
|
26
|
-
-
|
|
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.
|
|
27
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.`;
|
|
28
30
|
|
|
29
31
|
// ----- one persistent connection to the connector -----
|
|
@@ -69,14 +71,26 @@ async function rpc(method, params) {
|
|
|
69
71
|
|
|
70
72
|
// ----- tools -----
|
|
71
73
|
const tools = [
|
|
72
|
-
{ name: 'voice_connect', description: 'Connect this conversation to the voice room. Only on an explicit request to join or enable voice.',
|
|
73
|
-
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 } },
|
|
74
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.',
|
|
75
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 } },
|
|
76
80
|
{ name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
77
81
|
{ name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
78
82
|
];
|
|
79
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
|
+
}
|
|
80
94
|
function inboundFor(harness, thread) {
|
|
81
95
|
return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
|
|
82
96
|
}
|
|
@@ -88,12 +102,28 @@ async function invoke(name, args, meta) {
|
|
|
88
102
|
if (closed) binding = null;
|
|
89
103
|
const module = binding ? harnessFor(binding.harness) : null;
|
|
90
104
|
const inbound = binding ? inboundFor(module, binding.client_ref) : null;
|
|
91
|
-
return { joined: !!binding, room_reachable: status.connected, room_error: status.room_error || null,
|
|
105
|
+
return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null,
|
|
92
106
|
binding_id: binding?.binding_id || null, harness: binding?.harness || null,
|
|
93
107
|
capabilities: binding?.capabilities || null, inbound,
|
|
94
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.' } : {}) };
|
|
95
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.' };
|
|
123
|
+
}
|
|
96
124
|
if (name === 'voice_connect') {
|
|
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; }
|
|
97
127
|
const who = identifyHarness(meta);
|
|
98
128
|
const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
|
|
99
129
|
// Refuse rather than join a room we cannot hear from: a conversation whose harness holds
|
|
@@ -115,6 +145,7 @@ async function invoke(name, args, meta) {
|
|
|
115
145
|
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound };
|
|
116
146
|
}
|
|
117
147
|
if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
|
|
148
|
+
|
|
118
149
|
if (name === 'voice_say') {
|
|
119
150
|
let result;
|
|
120
151
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sidevoice/uplink",
|
|
3
|
-
"version": "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,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"cli.mjs",
|
|
15
|
+
"install.mjs",
|
|
15
16
|
"mcp.mjs",
|
|
16
17
|
"connector.mjs",
|
|
17
18
|
"harness-contract.mjs",
|
|
@@ -20,7 +21,6 @@
|
|
|
20
21
|
"harness-codex.mjs",
|
|
21
22
|
"harness-http.mjs",
|
|
22
23
|
"pair.mjs",
|
|
23
|
-
"hook.mjs",
|
|
24
24
|
"skill.mjs",
|
|
25
25
|
"skill/",
|
|
26
26
|
"README.md"
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|
|
@@ -2,22 +2,6 @@
|
|
|
2
2
|
name: voice-room
|
|
3
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
4
|
argument-hint: "[title for this conversation in the room]"
|
|
5
|
-
# Same shape as settings.json hooks (a list of groups): Claude Code 2.1.x registers nothing for a map here.
|
|
6
|
-
# The command names its harness: a Codex session started from this terminal inherits CLAUDE_CODE_SESSION_ID,
|
|
7
|
-
# so the hook cannot tell the two apart from the environment alone.
|
|
8
|
-
# The installer replaces __SIDEVOICE_SKILL_DIR__ with the directory it copies this file into: the hook must
|
|
9
|
-
# resolve without any variable, since ${CLAUDE_SKILL_DIR} is not expanded in a user skill's hook command.
|
|
10
|
-
hooks:
|
|
11
|
-
UserPromptSubmit:
|
|
12
|
-
- hooks:
|
|
13
|
-
- type: command
|
|
14
|
-
command: node "__SIDEVOICE_SKILL_DIR__/hook.mjs" --harness claude
|
|
15
|
-
timeout: 5
|
|
16
|
-
Stop:
|
|
17
|
-
- hooks:
|
|
18
|
-
- type: command
|
|
19
|
-
command: node "__SIDEVOICE_SKILL_DIR__/hook.mjs" --harness claude
|
|
20
|
-
timeout: 5
|
|
21
5
|
metadata:
|
|
22
6
|
sidevoice: installed copy; the source is skill/voice-room in @sidevoice/uplink, reinstall with `sidevoice skill install`
|
|
23
7
|
---
|
|
@@ -25,7 +9,8 @@ metadata:
|
|
|
25
9
|
Join the voice room for this conversation and keep it reachable.
|
|
26
10
|
|
|
27
11
|
1. Call `voice_status`. If it reports `joined` and `room_reachable`, say so in one line and stop.
|
|
28
|
-
2. Call `voice_connect` with the title `$ARGUMENTS` when given, otherwise a short label of what this conversation is about.
|
|
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.
|
|
29
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.
|
|
30
15
|
|
|
31
|
-
|
|
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.
|
package/skill.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
import {
|
|
1
|
+
/** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room
|
|
2
|
+
* (`/voice-room`). One file; running install again repairs it. A directory of the same name that is not
|
|
3
|
+
* ours is never touched. */
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -9,7 +9,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
export const SKILL_NAME = 'voice-room';
|
|
11
11
|
const MARKER = 'sidevoice: installed copy';
|
|
12
|
-
const HOOK_FILES = ['hook.mjs', 'harness-contract.mjs', 'harnesses.mjs', 'harness-claude.mjs', 'harness-codex.mjs', 'harness-http.mjs'];
|
|
13
12
|
|
|
14
13
|
export function skillsDir(argv = process.argv.slice(2), env = process.env) {
|
|
15
14
|
const index = argv.indexOf('--dir');
|
|
@@ -23,16 +22,17 @@ export function status(dir) {
|
|
|
23
22
|
if (!existsSync(target)) return { state: 'absent', target };
|
|
24
23
|
let ours = false;
|
|
25
24
|
try { ours = readFileSync(manifest, 'utf8').includes(MARKER); } catch {}
|
|
26
|
-
return { state: ours ? 'installed' : 'foreign', target
|
|
25
|
+
return { state: ours ? 'installed' : 'foreign', target };
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
export function install(dir) {
|
|
30
29
|
const current = status(dir);
|
|
31
30
|
if (current.state === 'foreign') throw new Error(`${current.target} already holds a skill that is not Sidevoice's; remove or rename it first.`);
|
|
32
31
|
mkdirSync(current.target, { recursive: true });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
36
|
return { ...status(dir), action: current.state === 'installed' ? 'updated' : 'installed' };
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -51,7 +51,7 @@ if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
|
|
|
51
51
|
if (!result) { console.error('usage: sidevoice skill <install|remove|status> [--dir <skills dir>]'); process.exit(2); }
|
|
52
52
|
console.log(`${result.action || result.state}: ${result.target}`);
|
|
53
53
|
if (result.action === 'installed' || result.action === 'updated') {
|
|
54
|
-
console.log('In Claude Code, /voice-room joins the room for that conversation
|
|
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
55
|
}
|
|
56
56
|
} catch (error) { console.error(error.message); process.exit(1); }
|
|
57
57
|
}
|
package/hook.mjs
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Harness hook: `sidevoice hook` on stdin reports lifecycle events for event-backed harnesses. When an admitted
|
|
3
|
-
* prompt is a Sidevoice voice message it also reports the read receipt and hands the harness context so the model
|
|
4
|
-
* speaks first. It never blocks the harness: any failure exits 0. */
|
|
5
|
-
import net from 'node:net';
|
|
6
|
-
import os from 'node:os';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { capabilityState, SUPPORTED, WORKING_EVENT } from './harness-contract.mjs';
|
|
9
|
-
import { harnesses, identifyHookHarness } from './harnesses.mjs';
|
|
10
|
-
|
|
11
|
-
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
12
|
-
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
13
|
-
|
|
14
|
-
/** The envelope a harness delivery module puts before the user's words. Anything else is not ours. */
|
|
15
|
-
export function voiceEnvelope(prompt) {
|
|
16
|
-
if (typeof prompt !== 'string' || !prompt.startsWith('{')) return null;
|
|
17
|
-
const end = prompt.indexOf('}');
|
|
18
|
-
if (end < 0) return null;
|
|
19
|
-
let header;
|
|
20
|
-
try { header = JSON.parse(prompt.slice(0, end + 1)); } catch { return null; }
|
|
21
|
-
if (!header || header.channel !== 'voice' || !header.message_id || !header.session_id || !Number.isInteger(header.revision)) return null;
|
|
22
|
-
return { message_id: header.message_id, session_id: header.session_id, revision: header.revision, text: prompt.slice(end + 1).trim() };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** What this hook invocation means, or null when it is not a Sidevoice voice message being admitted. */
|
|
26
|
-
export function interpret(payload, env = process.env) {
|
|
27
|
-
if (!payload || typeof payload !== 'object') return null;
|
|
28
|
-
const event = payload.hook_event_name || payload.hookEventName;
|
|
29
|
-
if (event && !/^userpromptsubmit$/i.test(String(event))) return null;
|
|
30
|
-
const envelope = voiceEnvelope(payload.prompt);
|
|
31
|
-
if (!envelope) return null;
|
|
32
|
-
const identity = identifyHookHarness(payload, env);
|
|
33
|
-
return identity ? { thread: identity.thread, turn_id: payload.turn_id || null, ...envelope } : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** A harness-native end-of-turn signal, normalized by that harness's module. */
|
|
37
|
-
export function interpretTurnEnd(payload, env = process.env) {
|
|
38
|
-
const identity = identifyHookHarness(payload, env);
|
|
39
|
-
if (!identity || identity.module.workingSource === WORKING_EVENT
|
|
40
|
-
|| capabilityState(identity.module, 'endOfTurn') !== SUPPORTED) return null;
|
|
41
|
-
return identity.module.endOfTurn(payload, env);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A lifecycle-backed harness working transition, plus Sidevoice correlation when this is our prompt. */
|
|
45
|
-
export function interpretWorking(payload, env = process.env) {
|
|
46
|
-
const identity = identifyHookHarness(payload, env);
|
|
47
|
-
if (!identity || capabilityState(identity.module, 'working') !== SUPPORTED
|
|
48
|
-
|| identity.module.workingSource !== WORKING_EVENT) return null;
|
|
49
|
-
const transition = identity.module.working(payload, env);
|
|
50
|
-
if (!transition) return null;
|
|
51
|
-
const reading = transition.working ? voiceEnvelope(payload.prompt) : null;
|
|
52
|
-
return reading
|
|
53
|
-
? { ...transition, session_id: reading.session_id, revision: reading.revision, message_id: reading.message_id }
|
|
54
|
-
: transition;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The harness this hook was installed for, as the hook command itself names it. Nothing is inferred
|
|
58
|
-
* from the environment: one harness's variables survive into another harness launched from its terminal. */
|
|
59
|
-
export function declaredHarness(argv = process.argv, env = process.env) {
|
|
60
|
-
const flag = argv.indexOf('--harness');
|
|
61
|
-
const named = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith('--harness='))?.slice('--harness='.length);
|
|
62
|
-
return named || env.SIDEVOICE_HOOK_HARNESS || null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The context handed back to the harness: the acknowledgement is asked for at the moment the message is read. */
|
|
66
|
-
export function nudge(reading) {
|
|
67
|
-
return `A voice message just arrived from the room (session ${reading.session_id}, revision ${reading.revision}). `
|
|
68
|
-
+ 'Before any other tool, publish a short spoken acknowledgement with voice_say that says what you understood and what you will do next, '
|
|
69
|
-
+ 'using that session_id and revision; then continue the work and publish the result by voice as well.';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function report(method, params, timeoutMs = 1500) {
|
|
73
|
-
return new Promise(resolve => {
|
|
74
|
-
const socket = net.createConnection(socketPath);
|
|
75
|
-
const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
|
|
76
|
-
const timer = setTimeout(() => done({ ok: false, error: 'timeout' }), timeoutMs);
|
|
77
|
-
let buffer = '';
|
|
78
|
-
socket.on('error', error => done({ ok: false, error: error.code || error.message }));
|
|
79
|
-
socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method, params }) + '\n'));
|
|
80
|
-
socket.on('data', chunk => {
|
|
81
|
-
buffer += chunk; const index = buffer.indexOf('\n');
|
|
82
|
-
if (index < 0) return;
|
|
83
|
-
try { done(JSON.parse(buffer.slice(0, index))); } catch { done({ ok: false, error: 'bad reply' }); }
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function main() {
|
|
89
|
-
let raw = '';
|
|
90
|
-
for await (const chunk of process.stdin) raw += chunk;
|
|
91
|
-
let payload = null;
|
|
92
|
-
try { payload = JSON.parse(raw); } catch {}
|
|
93
|
-
const named = declaredHarness();
|
|
94
|
-
if (named && !harnesses[named]) return console.error('[sidevoice hook] unknown harness: ' + named);
|
|
95
|
-
const env = named ? { ...process.env, SIDEVOICE_HOOK_HARNESS: named } : process.env;
|
|
96
|
-
const transition = interpretWorking(payload, env);
|
|
97
|
-
if (transition) {
|
|
98
|
-
const outcome = await report('working', transition);
|
|
99
|
-
if (!outcome.ok) console.error('[sidevoice hook] working report not sent: ' + (outcome.error || 'unknown'));
|
|
100
|
-
if (!transition.working) return;
|
|
101
|
-
}
|
|
102
|
-
const ended = interpretTurnEnd(payload, env);
|
|
103
|
-
if (ended) {
|
|
104
|
-
const outcome = await report('turn_end', ended);
|
|
105
|
-
if (!outcome.ok) console.error('[sidevoice hook] end-of-turn report not sent: ' + (outcome.error || 'unknown'));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const reading = interpret(payload, env);
|
|
109
|
-
if (!reading) return;
|
|
110
|
-
const outcome = await report('read', reading);
|
|
111
|
-
if (!outcome.ok) console.error('[sidevoice hook] read receipt not sent: ' + (outcome.error || 'unknown'));
|
|
112
|
-
if (process.env.SIDEVOICE_HOOK_NUDGE !== '0') {
|
|
113
|
-
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: nudge(reading) } }) + '\n');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (process.argv[1] && /hook\.mjs$/.test(process.argv[1]) || process.env.SIDEVOICE_HOOK_MAIN === '1') {
|
|
118
|
-
main().catch(error => { console.error('[sidevoice hook] ' + (error?.message || error)); }).finally(() => process.exit(0));
|
|
119
|
-
}
|