@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 CHANGED
@@ -1,9 +1,12 @@
1
1
  # Sidevoice uplink — the client side (`@sidevoice/uplink`)
2
2
 
3
- The client side. Three entry points behind one bin (`sidevoice`):
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|hook|skill> …` — one bin, five entry points. */
2
+ /** `sidevoice <install|mcp|pair|connector|skill> …` — one bin, five entry points. */
3
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); }
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, WORKING_POLL } from './harness-contract.mjs';
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: run pair.mjs first (looked in ${credentialsPath})`);
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
- return { url, connector_id, token };
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 hook that fires twice is harmless
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 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.
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
- * happens, and the same state is said again every few seconds regardless: a room that restarted, or a
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
- let workTimer = null;
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 watchWork() {
85
- if (workTimer) return;
86
- workTimer = setInterval(() => {
87
- if (!bindings.size) { clearInterval(workTimer); workTimer = null; return; }
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
- 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);
92
+ if (typeof binding.working !== 'boolean') continue;
93
+ if (Date.now() - (binding.workingSentAt || 0) >= WORK_ANNOUNCE_MS) announceWork(binding, binding.working);
100
94
  }
101
- }, WORK_POLL_MS);
102
- workTimer.unref?.();
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(); watchWork();
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
  }
@@ -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, WORKING_POLL } from './harness-contract.mjs';
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. A prompt-admitted hook supplies the
139
- * separate read receipt; this method reports only what the socket itself proves. */
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
- /** 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;
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
- working: sessionWorking,
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. Configured UserPromptSubmit and
4
- * Stop hooks report the mechanical start and end of every interactive turn. */
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 { defineHarness, envelope, SUPPORTED, UNSUPPORTED, WORKING_EVENT } from './harness-contract.mjs';
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
- 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;
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
- /** 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
+ /** 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
- working,
91
- endOfTurn,
122
+ observe,
92
123
  sessionIdentity,
93
124
  });
94
125
 
@@ -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
- if (state === SUPPORTED && typeof definition[capability] !== 'function') {
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 the voice skill expects before the user's literal words. */
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
- return JSON.stringify(header) + '\n\n' + event.text;
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
- - 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.
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.1",
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
- const [room, code] = process.argv.slice(2);
8
- if (!room || !code) { console.error('usage: pair.mjs <room-url> <pairing-code>'); process.exit(2); }
9
- const base = new URL(room);
10
- const response = await fetch(new URL('/api/connectors/pair', base), {
11
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, host: os.hostname() }),
12
- signal: AbortSignal.timeout(15_000),
13
- });
14
- const body = await response.json().catch(() => ({}));
15
- if (!response.ok) { console.error('Pairing failed: ' + (body.detail || response.status)); process.exit(1); }
16
- const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
17
- const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
18
- mkdirSync(dataDir, { recursive: true, mode: 0o700 });
19
- const file = path.join(dataDir, 'credentials.json');
20
- writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
21
- console.log(`Paired with ${base.origin} as connector ${body.connector_id}; credential saved to ${file}`);
11
+ export function dataDir(env = process.env) {
12
+ return env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
+ }
14
+
15
+ /** The room's http(s) origin from the socket address the credential stores. */
16
+ export function roomOrigin(wsUrl) {
17
+ const url = new URL(wsUrl); url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; return url.origin;
18
+ }
19
+
20
+ /** Which room this machine is paired with, or null. */
21
+ export function pairedRoom(env = process.env) {
22
+ try {
23
+ const saved = JSON.parse(readFileSync(path.join(dataDir(env), 'credentials.json'), 'utf8'));
24
+ if (!saved.url || !saved.connector_id || !saved.token) return null;
25
+ return { origin: roomOrigin(saved.url), connector_id: saved.connector_id };
26
+ } catch { return null; }
27
+ }
28
+
29
+ /** Redeem a code for this host's credential. Returns where it was written. */
30
+ export async function pair(room, code, env = process.env) {
31
+ const base = new URL(room);
32
+ const response = await fetch(new URL('/api/connectors/pair', base), {
33
+ method: 'POST', headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ code, host: os.hostname() }), signal: AbortSignal.timeout(15_000),
35
+ });
36
+ const body = await response.json().catch(() => ({}));
37
+ if (!response.ok) throw new Error('Pairing failed: ' + (body.detail || response.status));
38
+ const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
39
+ const directory = dataDir(env);
40
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
41
+ const file = path.join(directory, 'credentials.json');
42
+ writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
43
+ return { file, connector_id: body.connector_id, origin: base.origin };
44
+ }
45
+
46
+ if (process.env.SIDEVOICE_PAIR_MAIN === '1') {
47
+ const [room, code] = process.argv.slice(2);
48
+ if (!room || !code) { console.error('usage: sidevoice pair <room-url> <pairing-code> (the code is shown in the room under "Emparejar conector")'); process.exit(2); }
49
+ try {
50
+ const result = await pair(room, code);
51
+ console.log(`Paired with ${result.origin} as connector ${result.connector_id}; credential saved to ${result.file}`);
52
+ } catch (error) { console.error(error.message); process.exit(1); }
53
+ }
@@ -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
- 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.
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 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';
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, hook: existsSync(path.join(target, 'hook.mjs')) };
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
- 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));
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 and gives it read receipts. New sessions see the skill; a session already open needs a restart.');
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
- }