@sidevoice/uplink 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,14 +16,18 @@ The client side. Three entry points behind one bin (`sidevoice`):
16
16
  - `pair` — redeems a pairing code from the room UI for this machine's
17
17
  credential (`~/.sidevoice/credentials.json`, mode 0600).
18
18
 
19
- Adapters (`adapters.mjs`): `claude-uds` writes the voice envelope as a user
20
- message to the Claude Code session's inbox socket; `codex-queue` runs
21
- `codex queue --thread <id>`; `http` posts to any local receiver that accepts the
22
- room's message shape (for harnesses that have neither).
19
+ Harness modules implement one contract (`harness-contract.mjs`): delivery,
20
+ inbound inspection, mechanical working state (polled or lifecycle-backed), end-of-turn reporting and session
21
+ identity. Every capability is explicitly `supported` or `unsupported`; an old
22
+ or malformed declaration becomes `unknown`, never false. Claude Code, Codex and
23
+ generic HTTP each have one module. See the repository's
24
+ [`docs/HARNESS_CONTRACT.md`](../../docs/HARNESS_CONTRACT.md).
23
25
 
24
26
  Protocol (newline-free JSON over the WebSocket): `connector.hello` ->
25
- `connector.welcome`; `binding.register` -> `binding.registered|rejected`;
26
- `binding.unregister`; `input.deliver` -> `input.ack`; `speech.publish` ->
27
+ `connector.welcome`; `binding.register` (including declared harness capabilities)
28
+ -> `binding.registered|rejected`;
29
+ `binding.unregister`; `input.deliver` -> `input.ack`; `input.working`;
30
+ `speech.publish` ->
27
31
  `speech.published`; `heartbeat` <-> `heartbeat.ack`. Protocol version 1.
28
32
 
29
33
  Node 22+, no dependencies. Tests: `node --test test/test_connector.mjs`.
package/cli.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- /** `sidevoice <mcp|pair|connector> …` — one bin, three entry points. */
3
- const [, , command, ...rest] = process.argv;
4
- const entries = { mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs' };
5
- if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector> [args]'); process.exit(2); }
2
+ /** `sidevoice <mcp|pair|connector|hook|skill> …` — one bin, five entry points. */
3
+ const [, , command] = process.argv;
4
+ const entries = { mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', hook: './hook.mjs', skill: './skill.mjs' };
5
+ if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector|hook|skill> [args]'); process.exit(2); }
6
6
  process.argv.splice(2, 1);
7
+ if (command === 'hook') process.env.SIDEVOICE_HOOK_MAIN = '1';
8
+ if (command === 'skill') process.env.SIDEVOICE_SKILL_MAIN = '1';
7
9
  await import(entries[command]);
package/connector.mjs CHANGED
@@ -7,7 +7,8 @@ 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 { deliver } from './adapters.mjs';
10
+ import { capabilityState, SUPPORTED, WORKING_POLL } from './harness-contract.mjs';
11
+ import { harnessFor } from './harnesses.mjs';
11
12
 
12
13
  export const PROTOCOL = 1;
13
14
  const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
@@ -46,10 +47,12 @@ function acquireLock() {
46
47
  return false;
47
48
  }
48
49
 
49
- const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, owner, chain }
50
+ const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, capabilities, owner, chain }
50
51
  const registering = new Map(); // client_ref -> { resolve, reject, timer }
51
52
  const publishing = new Map(); // event_id -> { resolve, timer }
52
53
  const clients = new Set(); // façade IPC connections
54
+ 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 hook that fires twice is harmless
53
56
  let outbox = []; // speech frames not yet confirmed by the room
54
57
  let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
55
58
  let creds;
@@ -61,6 +64,44 @@ function saveOutbox() {
61
64
  }
62
65
  function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
63
66
 
67
+ /* Whether a conversation is working is the harness's own state: each module answers in the way it can
68
+ * (a polled registry, a stream of lifecycle events), and unknown or unsupported is skipped rather than
69
+ * rendered as false.
70
+ *
71
+ * The connector does not assume the room still knows what it was told. A transition is sent the moment it
72
+ * happens, and the same state is said again every few seconds regardless: a room that restarted, or a
73
+ * socket that dropped, learns what is true within one interval instead of waiting for the next change that
74
+ * may never come (a conversation that was already working when the room came back showed nothing at all,
75
+ * 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
+ const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
78
+ let workTimer = null;
79
+ function announceWork(binding, working, extra = {}) {
80
+ binding.working = working;
81
+ binding.workingSentAt = Date.now();
82
+ return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
83
+ }
84
+ function watchWork() {
85
+ if (workTimer) return;
86
+ workTimer = setInterval(() => {
87
+ if (!bindings.size) { clearInterval(workTimer); workTimer = null; return; }
88
+ for (const binding of bindings.values()) {
89
+ const harness = harnessFor(binding.harness);
90
+ if (capabilityState(harness, 'working') !== SUPPORTED) continue;
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);
100
+ }
101
+ }, WORK_POLL_MS);
102
+ workTimer.unref?.();
103
+ }
104
+
64
105
  function open() {
65
106
  if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
66
107
  const socket = ws = new WebSocket(creds.url);
@@ -90,7 +131,7 @@ async function receive(frame) {
90
131
  // Sending it back makes the room correctly reject it as foreign.
91
132
  const frame = { type: 'binding.register', client_ref: binding.client_ref,
92
133
  harness: binding.harness, thread: binding.thread, title: binding.title,
93
- inbound: binding.inbound, focus: false };
134
+ inbound: binding.inbound, capabilities: binding.capabilities, focus: false };
94
135
  if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
95
136
  send(frame);
96
137
  }
@@ -100,6 +141,7 @@ async function receive(frame) {
100
141
  case 'binding.registered': {
101
142
  const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
102
143
  if (binding && binding.binding_id !== frame.binding_id) { bindings.delete(binding.binding_id); binding.binding_id = frame.binding_id; bindings.set(frame.binding_id, binding); }
144
+ if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
103
145
  registering.get(frame.client_ref)?.resolve(frame); return;
104
146
  }
105
147
  case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
@@ -113,7 +155,8 @@ async function receive(frame) {
113
155
  // One delivery at a time per binding keeps the user's turns in order.
114
156
  binding.chain = (binding.chain || Promise.resolve()).then(async () => {
115
157
  try {
116
- const outcome = await deliver(binding.delivery, frame);
158
+ const harness = harnessFor(binding.harness);
159
+ const outcome = await harness.deliver(binding.delivery, frame);
117
160
  console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
118
161
  send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
119
162
  } catch (error) {
@@ -123,13 +166,23 @@ async function receive(frame) {
123
166
  });
124
167
  return;
125
168
  }
169
+ case 'binding.close': {
170
+ // The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
171
+ const binding = bindings.get(frame.binding_id);
172
+ if (!binding) return;
173
+ bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding);
174
+ closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
175
+ console.error(`[sidevoice] room closed voice for ${binding.thread}`);
176
+ scheduleExit(); return;
177
+ }
126
178
  case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
127
179
  }
128
180
  }
129
181
 
130
182
  function snapshot() {
131
- return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError,
132
- bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery }) => ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind })) };
183
+ return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
184
+ bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
185
+ ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
133
186
  }
134
187
  function scheduleExit() {
135
188
  if (idleTimer) clearTimeout(idleTimer);
@@ -147,23 +200,28 @@ async function command(client, input) {
147
200
  const params = input.params || {};
148
201
  switch (input.method) {
149
202
  case 'register': {
150
- const { client_ref, harness, thread, title, delivery, inbound } = params;
203
+ const { client_ref, harness, thread, title, delivery, inbound, capabilities, engine } = params;
151
204
  if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
205
+ closedByRoom.delete(client_ref); // joining again is the user's explicit request
152
206
  const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
153
- if (existing) { existing.owner = client; existing.delivery = delivery; client.bindings.add(existing); return { binding_id: existing.binding_id, thread, connected }; }
207
+ if (existing) {
208
+ Object.assign(existing, { owner: client, delivery, inbound, capabilities });
209
+ client.bindings.add(existing);
210
+ return { binding_id: existing.binding_id, thread, connected };
211
+ }
154
212
  const local_id = 'local-' + randomUUID();
155
- const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, owner: client };
156
- bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open();
213
+ 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(); watchWork();
157
215
  const frame = await new Promise((resolve, reject) => {
158
216
  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);
159
217
  registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
160
- if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound })) { /* sent on welcome */ }
218
+ if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
161
219
  }).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); throw error; });
162
220
  return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
163
221
  }
164
222
  case 'publish': {
165
223
  const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
166
- if (!binding) throw new Error('Unknown binding');
224
+ if (!binding) throw new Error(closedByRoom.has(params.client_ref) ? 'CLOSED_BY_ROOM' : 'Unknown binding');
167
225
  const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
168
226
  session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
169
227
  outbox.push(speech); saveOutbox();
@@ -176,6 +234,48 @@ async function command(client, input) {
176
234
  const { type, event_id, ...result } = reply;
177
235
  return result;
178
236
  }
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
+ }
179
279
  case 'unregister': {
180
280
  const binding = bindings.get(params.binding_id);
181
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 }); }
@@ -8,8 +8,10 @@
8
8
  * Documented at https://code.claude.com/docs/en/cross-session-messaging */
9
9
  import { execFileSync } from 'node:child_process';
10
10
  import { readFileSync, readdirSync } from 'node:fs';
11
+ import net from 'node:net';
11
12
  import os from 'node:os';
12
13
  import path from 'node:path';
14
+ import { defineHarness, envelope, SUPPORTED, WORKING_POLL } from './harness-contract.mjs';
13
15
 
14
16
  const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
15
17
 
@@ -17,6 +19,28 @@ function readJson(file) {
17
19
  try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
18
20
  }
19
21
 
22
+ /** The record Claude Code keeps for a session, or null. It publishes `status` there and keeps it current. */
23
+ export function sessionRecord(sessionId) {
24
+ const registry = path.join(configDir(), 'sessions');
25
+ let entries = [];
26
+ try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
27
+ for (const name of entries) {
28
+ const record = readJson(path.join(registry, name));
29
+ if (record?.sessionId === sessionId) return record;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Whether that session is working on something right now: true, false, or null when it cannot be told.
35
+ * This is Claude Code's own bookkeeping, not a published interface: an unknown value answers null rather
36
+ * than guessing, and the room falls back to what the conversation says about its own replies. */
37
+ export function sessionWorking(sessionId) {
38
+ const status = sessionRecord(sessionId)?.status;
39
+ if (status === 'busy') return true;
40
+ if (status === 'idle' || status === 'ready' || status === 'waiting') return false;
41
+ return null;
42
+ }
43
+
20
44
  /** The pid of the session with this id, from Claude Code's own session registry. */
21
45
  function sessionPid(sessionId) {
22
46
  const registry = path.join(configDir(), 'sessions');
@@ -52,6 +76,16 @@ function settingsFromFlag(args) {
52
76
 
53
77
  const BYPASS_MODES = new Set(['bypassPermissions']);
54
78
 
79
+ /** Which model this conversation runs, read from the session's own launch line — no model is asked to
80
+ * say what it is. Absent rather than guessed when the launcher did not name one (the CLI's default). */
81
+ export function sessionEngine(sessionId) {
82
+ const args = launchArgs(sessionPid(sessionId));
83
+ if (!args) return null;
84
+ const model = flag(args, '--model'), effort = flag(args, '--effort'), thinking = flag(args, '--thinking');
85
+ if (!model && !effort) return null;
86
+ return { model: model || null, effort: effort || null, thinking: thinking || null };
87
+ }
88
+
55
89
  /** Will an injected message be delivered to this Claude session, or held for its user? */
56
90
  export function inspectInbound(sessionId) {
57
91
  const pid = sessionPid(sessionId);
@@ -86,3 +120,70 @@ export function inspectInbound(sessionId) {
86
120
  confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
87
121
  };
88
122
  }
123
+
124
+ /** Identity and private inbox inherited by the MCP façade Claude Code spawned. */
125
+ export function sessionIdentity({ env = process.env } = {}) {
126
+ if (!env.CLAUDE_CODE_SESSION_ID) return null;
127
+ return {
128
+ harness: 'claude',
129
+ thread: env.CLAUDE_CODE_SESSION_ID,
130
+ ...(env.CLAUDE_CODE_MESSAGING_SOCKET ? { delivery: {
131
+ kind: 'claude-uds',
132
+ socket: env.CLAUDE_CODE_MESSAGING_SOCKET,
133
+ token: env.CLAUDE_CODE_MESSAGING_TOKEN || '',
134
+ } } : {}),
135
+ };
136
+ }
137
+
138
+ /** Claude Code's session inbox sends no acknowledgement. A prompt-admitted hook supplies the
139
+ * separate read receipt; this method reports only what the socket itself proves. */
140
+ export function deliver(delivery, event) {
141
+ if (delivery?.kind !== 'claude-uds') throw new Error(`Unsupported Claude delivery kind: ${delivery?.kind}`);
142
+ return new Promise((resolve, reject) => {
143
+ const started = Date.now();
144
+ const socket = net.createConnection(delivery.socket);
145
+ let settled = false, wrote = 0, replied = '';
146
+ const finish = (error, status, detail) => {
147
+ if (settled) return;
148
+ settled = true; clearTimeout(timer); socket.destroy();
149
+ if (error) return reject(error);
150
+ resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
151
+ };
152
+ const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
153
+ socket.on('error', error => finish(error));
154
+ socket.on('data', chunk => { replied += chunk; });
155
+ socket.on('connect', () => {
156
+ socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
157
+ socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
158
+ wrote = Date.now();
159
+ });
160
+ socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
161
+ });
162
+ }
163
+
164
+ /** A configured Stop hook is a direct end-of-turn signal, independent of session polling. */
165
+ export function endOfTurn(payload, env = process.env) {
166
+ if (!payload || !/^stop$/i.test(String(payload.hook_event_name || payload.hookEventName || ''))) return null;
167
+ const identity = sessionIdentity({ env });
168
+ return identity ? { thread: identity.thread, turn_id: payload.turn_id || null } : null;
169
+ }
170
+
171
+ export const claudeHarness = defineHarness({
172
+ name: 'claude',
173
+ workingSource: WORKING_POLL,
174
+ capabilities: {
175
+ deliver: SUPPORTED,
176
+ inspectInbound: SUPPORTED,
177
+ working: SUPPORTED,
178
+ endOfTurn: SUPPORTED,
179
+ sessionIdentity: SUPPORTED,
180
+ },
181
+ deliver,
182
+ inspectInbound,
183
+ engine: sessionEngine,
184
+ working: sessionWorking,
185
+ endOfTurn,
186
+ sessionIdentity,
187
+ });
188
+
189
+ export default claudeHarness;
@@ -0,0 +1,95 @@
1
+ /** Codex harness implementation.
2
+ *
3
+ * Delivery and identity are available to the stdio MCP façade. Configured UserPromptSubmit and
4
+ * Stop hooks report the mechanical start and end of every interactive turn. */
5
+ import { execFile } from 'node:child_process';
6
+ import { defineHarness, envelope, SUPPORTED, UNSUPPORTED, WORKING_EVENT } from './harness-contract.mjs';
7
+
8
+ function turnMetadata(meta) {
9
+ let turn = meta?.['x-codex-turn-metadata'] || {};
10
+ if (typeof turn === 'string') {
11
+ try { turn = JSON.parse(turn); } catch { turn = {}; }
12
+ }
13
+ return turn;
14
+ }
15
+
16
+ function sessionIdentity({ meta, env = process.env, payload } = {}) {
17
+ const turn = turnMetadata(meta);
18
+ const thread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId
19
+ || meta?.codex_thread_id || turn.thread_id || payload?.session_id || payload?.thread_id
20
+ || env.CODEX_THREAD_ID;
21
+ if (!thread) return null;
22
+ const delivery = env.SIDEVOICE_DELIVERY_URL
23
+ ? { kind: 'http', url: env.SIDEVOICE_DELIVERY_URL, thread }
24
+ : { kind: 'codex-queue', thread };
25
+ return { harness: 'codex', thread, delivery };
26
+ }
27
+
28
+ function deliver(delivery, event) {
29
+ if (delivery?.kind === 'http') {
30
+ return fetch(delivery.url, {
31
+ method: 'POST',
32
+ headers: { 'content-type': 'application/json' },
33
+ body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
34
+ session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
35
+ signal: AbortSignal.timeout(30_000),
36
+ }).then(async response => {
37
+ if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
38
+ return { status: 'accepted', detail: `receiver answered ${response.status}` };
39
+ });
40
+ }
41
+ if (delivery?.kind !== 'codex-queue') throw new Error(`Unsupported Codex delivery kind: ${delivery?.kind}`);
42
+ return new Promise((resolve, reject) => {
43
+ const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
44
+ const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
45
+ execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
46
+ if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
47
+ resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
48
+ });
49
+ });
50
+ }
51
+
52
+ /** Which model this thread runs, from the launch line of the process that owns it, when it says. */
53
+ function engine(thread, env = process.env) {
54
+ const named = env.CODEX_MODEL || null;
55
+ return named ? { model: named, effort: env.CODEX_REASONING_EFFORT || null, thinking: null } : null;
56
+ }
57
+
58
+ function endOfTurn(payload, env = process.env) {
59
+ if (!payload || !/^stop$/i.test(String(payload.hook_event_name || payload.hookEventName || ''))) return null;
60
+ const identity = sessionIdentity({ payload, env });
61
+ return identity ? { thread: identity.thread, turn_id: payload.turn_id || null } : null;
62
+ }
63
+
64
+ /** A Codex lifecycle hook, normalized as a per-thread working transition. */
65
+ function working(payload, env = process.env) {
66
+ const event = String(payload?.hook_event_name || payload?.hookEventName || '');
67
+ if (!/^(userpromptsubmit|stop)$/i.test(event)) return null;
68
+ const identity = sessionIdentity({ payload, env });
69
+ if (!identity || typeof identity.thread !== 'string' || !identity.thread
70
+ || typeof payload.turn_id !== 'string' || !payload.turn_id) return null;
71
+ return {
72
+ thread: identity.thread,
73
+ turn_id: payload.turn_id || null,
74
+ working: /^userpromptsubmit$/i.test(event),
75
+ };
76
+ }
77
+
78
+ export const codexHarness = defineHarness({
79
+ name: 'codex',
80
+ workingSource: WORKING_EVENT,
81
+ capabilities: {
82
+ deliver: SUPPORTED,
83
+ inspectInbound: UNSUPPORTED,
84
+ working: SUPPORTED,
85
+ endOfTurn: SUPPORTED,
86
+ sessionIdentity: SUPPORTED,
87
+ },
88
+ deliver,
89
+ engine,
90
+ working,
91
+ endOfTurn,
92
+ sessionIdentity,
93
+ });
94
+
95
+ export default codexHarness;
@@ -0,0 +1,53 @@
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
+ export const WORKING_POLL = 'poll';
16
+ export const WORKING_EVENT = 'event';
17
+ const DECLARED_STATES = new Set([SUPPORTED, UNSUPPORTED]);
18
+ const WORKING_SOURCES = new Set([WORKING_POLL, WORKING_EVENT]);
19
+
20
+ export function capabilityState(harness, capability) {
21
+ const state = harness?.capabilities?.[capability];
22
+ return DECLARED_STATES.has(state) ? state : UNKNOWN;
23
+ }
24
+
25
+ export function advertisedCapabilities(harness) {
26
+ return Object.fromEntries(CAPABILITIES.map(capability => [capability, capabilityState(harness, capability)]));
27
+ }
28
+
29
+ export function defineHarness(definition) {
30
+ if (!definition?.name) throw new Error('A harness needs a name');
31
+ for (const capability of CAPABILITIES) {
32
+ const state = definition.capabilities?.[capability];
33
+ if (!DECLARED_STATES.has(state)) throw new Error(`${definition.name} must declare ${capability}`);
34
+ if (state === SUPPORTED && typeof definition[capability] !== 'function') {
35
+ throw new Error(`${definition.name} declares ${capability} supported but does not implement it`);
36
+ }
37
+ }
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
+ return Object.freeze({ ...definition, capabilities: Object.freeze({ ...definition.capabilities }) });
42
+ }
43
+
44
+ /** The header the voice skill expects before the user's literal words. */
45
+ export function envelope(event) {
46
+ const header = {
47
+ channel: event.channel === 'room-control' ? 'room-control' : 'voice',
48
+ session_id: event.session_id,
49
+ revision: event.revision,
50
+ message_id: event.message_id,
51
+ };
52
+ return JSON.stringify(header) + '\n\n' + event.text;
53
+ }
@@ -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,36 @@
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
+ }
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/hook.mjs ADDED
@@ -0,0 +1,119 @@
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
+ }
package/mcp.mjs CHANGED
@@ -7,7 +7,8 @@ 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';
11
12
 
12
13
  const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
14
  const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
@@ -20,29 +21,11 @@ const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice r
20
21
  - 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
22
  - 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
23
  - 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
+ - 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.
24
25
  - voice_status reports whether the room can currently reach this conversation.
26
+ - On Claude Code, if a skill named voice-room is available, joining through it (/voice-room) is preferred: it registers, for this session only, the hook that gives the room read receipts and asks you to speak first.
25
27
  - 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
28
 
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
29
  // ----- one persistent connection to the connector -----
47
30
  let ipc = null, ipcBuffer = '', ipcSerial = 0;
48
31
  const ipcWaiting = new Map();
@@ -94,32 +77,55 @@ const tools = [
94
77
  { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
95
78
  ];
96
79
  let binding = null;
80
+ function inboundFor(harness, thread) {
81
+ return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
82
+ }
97
83
  async function invoke(name, args, meta) {
98
84
  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 };
85
+ const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [], closed_by_room: [] };
86
+ // The room may have closed this conversation's voice since we joined: the connector is the truth.
87
+ const closed = !!binding && (status.closed_by_room || []).includes(binding.client_ref);
88
+ if (closed) binding = null;
89
+ const module = binding ? harnessFor(binding.harness) : null;
90
+ const inbound = binding ? inboundFor(module, binding.client_ref) : null;
101
91
  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 };
92
+ binding_id: binding?.binding_id || null, harness: binding?.harness || null,
93
+ capabilities: binding?.capabilities || null, inbound,
94
+ ...(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.' } : {}) };
103
95
  }
104
96
  if (name === 'voice_connect') {
105
- const who = identity(meta);
97
+ const who = identifyHarness(meta);
106
98
  const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
107
99
  // Refuse rather than join a room we cannot hear from: a conversation whose harness holds
108
100
  // 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) {
101
+ const inbound = inboundFor(who.module, who.thread);
102
+ if (inbound?.ok === false) {
111
103
  const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
112
104
  error.data = { inbound };
113
105
  throw error;
114
106
  }
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 };
107
+ const capabilities = advertisedCapabilities(who.module);
108
+ // Which model is answering, read from the session's own launch line rather than asked of the model.
109
+ let engine = null;
110
+ try { engine = who.module.engine?.(who.thread) || null; } catch { engine = null; }
111
+ const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
112
+ title, delivery: who.delivery, inbound, capabilities, engine });
113
+ binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
117
114
  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 };
115
+ binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound };
119
116
  }
120
117
  if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
121
118
  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 });
119
+ let result;
120
+ try {
121
+ 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 });
122
+ } catch (error) {
123
+ if (error.message === 'CLOSED_BY_ROOM') {
124
+ binding = null;
125
+ 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.');
126
+ }
127
+ throw error;
128
+ }
123
129
  return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
124
130
  }
125
131
  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.3.1",
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",
@@ -14,9 +14,15 @@
14
14
  "cli.mjs",
15
15
  "mcp.mjs",
16
16
  "connector.mjs",
17
- "adapters.mjs",
17
+ "harness-contract.mjs",
18
+ "harnesses.mjs",
18
19
  "harness-claude.mjs",
20
+ "harness-codex.mjs",
21
+ "harness-http.mjs",
19
22
  "pair.mjs",
23
+ "hook.mjs",
24
+ "skill.mjs",
25
+ "skill/",
20
26
  "README.md"
21
27
  ],
22
28
  "scripts": {
@@ -0,0 +1,31 @@
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
+ # 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
+ metadata:
22
+ sidevoice: installed copy; the source is skill/voice-room in @sidevoice/uplink, reinstall with `sidevoice skill install`
23
+ ---
24
+
25
+ Join the voice room for this conversation and keep it reachable.
26
+
27
+ 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.
29
+ 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
+
31
+ From now on this conversation has read receipts and mechanical working-state reporting: invoking this skill registered hooks that tell the room when a voice message is admitted and when the turn stops. Voice messages arrive as user messages that begin with a JSON header (`channel`, `session_id`, `revision`, `message_id`) followed by the user's words. For each one, publish a short `voice_say` with that `session_id` and `revision` before any other tool, then do the work and publish the result by voice as well. A repeated `message_id` is a redelivery: do not act on it twice.
package/skill.mjs ADDED
@@ -0,0 +1,57 @@
1
+ /** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room and,
2
+ * for that session only, registers the lifecycle hook. It installs the skill plus the hook's harness runtime;
3
+ * running install again repairs it. A directory of the same name that is not ours is never touched. */
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ export const SKILL_NAME = 'voice-room';
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
+
14
+ export function skillsDir(argv = process.argv.slice(2), env = process.env) {
15
+ const index = argv.indexOf('--dir');
16
+ if (index >= 0 && argv[index + 1]) return path.resolve(argv[index + 1]);
17
+ return path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'skills');
18
+ }
19
+
20
+ export function status(dir) {
21
+ const target = path.join(dir, SKILL_NAME);
22
+ const manifest = path.join(target, 'SKILL.md');
23
+ if (!existsSync(target)) return { state: 'absent', target };
24
+ let ours = false;
25
+ try { ours = readFileSync(manifest, 'utf8').includes(MARKER); } catch {}
26
+ return { state: ours ? 'installed' : 'foreign', target, hook: existsSync(path.join(target, 'hook.mjs')) };
27
+ }
28
+
29
+ export function install(dir) {
30
+ const current = status(dir);
31
+ if (current.state === 'foreign') throw new Error(`${current.target} already holds a skill that is not Sidevoice's; remove or rename it first.`);
32
+ mkdirSync(current.target, { recursive: true });
33
+ const manifest = readFileSync(path.join(here, 'skill', SKILL_NAME, 'SKILL.md'), 'utf8').replaceAll('__SIDEVOICE_SKILL_DIR__', current.target);
34
+ writeFileSync(path.join(current.target, 'SKILL.md'), manifest);
35
+ for (const file of HOOK_FILES) cpSync(path.join(here, file), path.join(current.target, file));
36
+ return { ...status(dir), action: current.state === 'installed' ? 'updated' : 'installed' };
37
+ }
38
+
39
+ export function remove(dir) {
40
+ const current = status(dir);
41
+ if (current.state === 'foreign') throw new Error(`${current.target} is not Sidevoice's skill; left as it is.`);
42
+ if (current.state === 'installed') rmSync(current.target, { recursive: true, force: true });
43
+ return { state: 'absent', target: current.target, action: current.state === 'installed' ? 'removed' : 'nothing to remove' };
44
+ }
45
+
46
+ if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
47
+ const [command] = process.argv.slice(2);
48
+ const dir = skillsDir();
49
+ try {
50
+ const result = command === 'install' ? install(dir) : command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
51
+ if (!result) { console.error('usage: sidevoice skill <install|remove|status> [--dir <skills dir>]'); process.exit(2); }
52
+ console.log(`${result.action || result.state}: ${result.target}`);
53
+ if (result.action === 'installed' || result.action === 'updated') {
54
+ console.log('In Claude Code, /voice-room joins the room for that conversation and gives it read receipts. New sessions see the skill; a session already open needs a restart.');
55
+ }
56
+ } catch (error) { console.error(error.message); process.exit(1); }
57
+ }
package/adapters.mjs DELETED
@@ -1,69 +0,0 @@
1
- /** The last mile, one function per harness. Each takes a binding's delivery target and a room event. */
2
- import net from 'node:net';
3
- import { execFile } from 'node:child_process';
4
-
5
- /** The header the skill expects before the user's literal words. */
6
- export function envelope(event) {
7
- const header = { channel: event.channel === 'room-control' ? 'room-control' : 'voice',
8
- session_id: event.session_id, revision: event.revision, message_id: event.message_id };
9
- return JSON.stringify(header) + '\n\n' + event.text;
10
- }
11
-
12
- /** Claude Code: the session's own inbox socket, inherited by the façade that registered the binding.
13
- * The inbox sends no acknowledgement, so this can never report more than what the wire showed:
14
- * the peer hanging up right after the frames is the one observable sign of a refusal. */
15
- function deliverClaude(delivery, event) {
16
- return new Promise((resolve, reject) => {
17
- const started = Date.now();
18
- const socket = net.createConnection(delivery.socket);
19
- let settled = false, wrote = 0, replied = '';
20
- const finish = (error, status, detail) => {
21
- if (settled) return;
22
- settled = true; clearTimeout(timer); socket.destroy();
23
- if (error) return reject(error);
24
- resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
25
- };
26
- const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
27
- socket.on('error', error => finish(error));
28
- socket.on('data', chunk => { replied += chunk; });
29
- socket.on('connect', () => {
30
- socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
31
- socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
32
- wrote = Date.now();
33
- });
34
- // A hang-up right after the frames is how a refused auth or a closed inbox looks from here.
35
- socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
36
- });
37
- }
38
-
39
- /** Codex: `codex queue` enqueues the next user turn on the local app-server daemon. */
40
- function deliverCodex(delivery, event) {
41
- return new Promise((resolve, reject) => {
42
- const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
43
- const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
44
- execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
45
- if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
46
- resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
47
- });
48
- });
49
- }
50
-
51
- /** Fallback: an HTTP receiver next to the harness (the slimmed Codex Desktop bridge speaks this). */
52
- async function deliverHttp(delivery, event) {
53
- const response = await fetch(delivery.url, {
54
- method: 'POST', headers: { 'content-type': 'application/json' },
55
- body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
56
- session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
57
- signal: AbortSignal.timeout(30_000),
58
- });
59
- if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
60
- return { status: 'accepted', detail: `receiver answered ${response.status}` };
61
- }
62
-
63
- export const adapters = { 'claude-uds': deliverClaude, 'codex-queue': deliverCodex, http: deliverHttp };
64
-
65
- export function deliver(delivery, event) {
66
- const adapter = adapters[delivery?.kind];
67
- if (!adapter) throw new Error(`Unknown delivery kind: ${delivery?.kind}`);
68
- return adapter(delivery, event);
69
- }