@sidevoice/uplink 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@sidevoice/uplink",
3
+ "version": "0.5.0",
4
+ "description": "Sidevoice client side: the stdio MCP server your agent uses, one outbound uplink per machine to the room, one-time pairing.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "bin": {
11
+ "sidevoice": "./dist/cli.mjs"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "node build.mjs",
19
+ "prepack": "npm run build",
20
+ "test": "node --test test/test_connector.mjs"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/rubasace/sidevoice.git",
25
+ "directory": "packages/connector"
26
+ },
27
+ "devDependencies": {
28
+ "esbuild": "0.25.12",
29
+ "socket.io": "^4.8.3",
30
+ "socket.io-client": "^4.8.3"
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidevoice/uplink",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -8,29 +8,25 @@
8
8
  "node": ">=22"
9
9
  },
10
10
  "bin": {
11
- "sidevoice": "./cli.mjs"
11
+ "sidevoice": "./dist/cli.mjs"
12
12
  },
13
13
  "files": [
14
- "cli.mjs",
15
- "install.mjs",
16
- "mcp.mjs",
17
- "connector.mjs",
18
- "harness-contract.mjs",
19
- "harnesses.mjs",
20
- "harness-claude.mjs",
21
- "harness-codex.mjs",
22
- "harness-http.mjs",
23
- "pair.mjs",
24
- "skill.mjs",
25
- "skill/",
14
+ "dist",
26
15
  "README.md"
27
16
  ],
28
17
  "scripts": {
18
+ "build": "node build.mjs",
19
+ "prepack": "npm run build",
29
20
  "test": "node --test test/test_connector.mjs"
30
21
  },
31
22
  "repository": {
32
23
  "type": "git",
33
24
  "url": "https://github.com/rubasace/sidevoice.git",
34
25
  "directory": "packages/connector"
26
+ },
27
+ "devDependencies": {
28
+ "esbuild": "0.25.12",
29
+ "socket.io": "^4.8.3",
30
+ "socket.io-client": "^4.8.3"
35
31
  }
36
32
  }
package/cli.mjs DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env node
2
- /** `sidevoice <install|mcp|pair|connector|skill> …` — one bin, five entry points. */
3
- const [, , command] = process.argv;
4
- const entries = { install: './install.mjs', mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', skill: './skill.mjs' };
5
- if (!entries[command]) { console.error('usage: sidevoice <install|mcp|pair|connector|skill> [args]'); process.exit(2); }
6
- process.argv.splice(2, 1);
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';
10
- await import(entries[command]);
package/connector.mjs DELETED
@@ -1,322 +0,0 @@
1
- #!/usr/bin/env node
2
- /** One connector per host: an outbound WebSocket to the room, every binding multiplexed over it,
3
- * and the last mile chosen per binding. Node 22+, no dependencies. Façades talk to it over a
4
- * local socket; a binding lives exactly as long as the façade connection that registered it. */
5
- import net from 'node:net';
6
- import os from 'node:os';
7
- import path from 'node:path';
8
- import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
9
- import { randomUUID } from 'node:crypto';
10
- import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
11
- import { harnessFor } from './harnesses.mjs';
12
- import { privateNetwork } from './pair.mjs';
13
- import { fileURLToPath } from 'node:url';
14
-
15
- const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
16
-
17
- export const PROTOCOL = 1;
18
- const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
19
- const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
20
- const lockPath = socketPath + '.lock';
21
- const outboxPath = path.join(dataDir, 'outbox.json');
22
- const credentialsPath = process.env.SIDEVOICE_CREDENTIALS || path.join(dataDir, 'credentials.json');
23
- const idleMs = Number(process.env.SIDEVOICE_CONNECTOR_IDLE_MS || 15_000);
24
- const hostId = process.env.SIDEVOICE_HOST_ID || os.hostname();
25
-
26
- function credentials() {
27
- let saved = {};
28
- try { saved = JSON.parse(readFileSync(credentialsPath, 'utf8')); } catch {}
29
- const url = process.env.SIDEVOICE_URL || saved.url;
30
- const connector_id = process.env.SIDEVOICE_CONNECTOR_ID || saved.connector_id;
31
- const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
32
- 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`);
33
- const parsed = new URL(url);
34
- if (parsed.protocol !== 'wss:' && !privateNetwork(parsed.hostname)) throw new Error('The room URL must be wss:// unless it stays on this machine or inside its cluster');
35
- const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
36
- return { url, connector_id, token, room: room.origin };
37
- }
38
-
39
- function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
40
- function acquireLock() {
41
- mkdirSync(dataDir, { recursive: true, mode: 0o700 });
42
- for (let attempt = 0; attempt < 2; attempt++) {
43
- try { const fd = openSync(lockPath, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd); return true; }
44
- catch (error) {
45
- if (error.code !== 'EEXIST') throw error;
46
- let pid = 0; try { pid = Number(readFileSync(lockPath, 'utf8')); } catch {}
47
- if (pid && alive(pid)) return false; // A live connector holds it: we are redundant.
48
- try { unlinkSync(lockPath); } catch {} // Stale lock from a dead process.
49
- }
50
- }
51
- return false;
52
- }
53
-
54
- const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, capabilities, owner, chain }
55
- const registering = new Map(); // client_ref -> { resolve, reject, timer }
56
- const publishing = new Map(); // event_id -> { resolve, timer }
57
- const clients = new Set(); // façade IPC connections
58
- const closedByRoom = new Map(); // client_ref -> reason: the user closed that conversation's voice from the room
59
- const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
60
- let outbox = []; // speech frames not yet confirmed by the room
61
- let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
62
- let creds;
63
-
64
- function loadOutbox() { try { outbox = JSON.parse(readFileSync(outboxPath, 'utf8')); if (!Array.isArray(outbox)) outbox = []; } catch { outbox = []; } }
65
- function saveOutbox() {
66
- const temporary = outboxPath + '.' + process.pid + '.tmp';
67
- writeFileSync(temporary, JSON.stringify(outbox), { mode: 0o600 }); renameSync(temporary, outboxPath);
68
- }
69
- function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
70
-
71
- /* Whether a conversation is working, and whether it has read what the room sent, are the harness's own
72
- * state — and every harness writes that state down somewhere of its own: Claude Code in a session registry
73
- * and a transcript, Codex in the thread's rollout. Each module watches what its harness writes (`observe`)
74
- * and calls back; nothing is installed in the harness for it. Unknown or unsupported is skipped rather
75
- * than rendered as false.
76
- *
77
- * The connector does not assume the room still knows what it was told. A transition is sent the moment it
78
- * is seen, and the same state is said again every few seconds regardless: a room that restarted, or a
79
- * socket that dropped, learns what is true within one interval instead of waiting for the next change that
80
- * may never come (a conversation that was already working when the room came back showed nothing at all,
81
- * 2026-09-20). Saying it again is one small frame; not saying it is a light that never comes on. */
82
- const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
83
- const PENDING_MAX = 64;
84
- let announceTimer = null;
85
- function announceWork(binding, working, extra = {}) {
86
- binding.working = working;
87
- binding.workingSentAt = Date.now();
88
- return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
89
- }
90
- function keepAnnouncing() {
91
- if (announceTimer) return;
92
- announceTimer = setInterval(() => {
93
- if (!bindings.size) { clearInterval(announceTimer); announceTimer = null; return; }
94
- for (const binding of bindings.values()) {
95
- if (typeof binding.working !== 'boolean') continue;
96
- if (Date.now() - (binding.workingSentAt || 0) >= WORK_ANNOUNCE_MS) announceWork(binding, binding.working);
97
- }
98
- }, Math.max(50, WORK_ANNOUNCE_MS / 4));
99
- announceTimer.unref?.();
100
- }
101
- /** Start watching a binding's conversation through its harness. What we delivered and it has not yet taken
102
- * waits in `pending`; the moment its transcript shows the message, the room gets the second tick and a
103
- * correlated start of turn; the end of that turn carries the same correlation. */
104
- function watch(binding) {
105
- const harness = harnessFor(binding.harness);
106
- if (binding.stop || capabilityState(harness, 'working') !== SUPPORTED || typeof harness.observe !== 'function') return;
107
- binding.pending ||= new Map();
108
- const correlation = () => binding.turn ? { turn_id: binding.turn.turn_id, session_id: binding.turn.session_id, revision: binding.turn.revision } : {};
109
- binding.stop = harness.observe(binding.thread, {
110
- userMessage({ text, turn_id }) {
111
- const header = voiceEnvelope(text);
112
- if (!header || !binding.pending.has(header.message_id)) return;
113
- binding.pending.delete(header.message_id);
114
- if (!readReported.has(header.message_id)) {
115
- readReported.add(header.message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
116
- 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 });
117
- console.error(`[sidevoice] ${binding.thread} read ${header.message_id}`);
118
- }
119
- if (header.channel !== 'voice') return;
120
- binding.turn = { turn_id: turn_id || null, session_id: header.session_id, revision: header.revision };
121
- announceWork(binding, true, turn_id ? { turn_phase: 'start', ...correlation() } : {});
122
- },
123
- working(working, { turn_id } = {}) {
124
- if (working) {
125
- // A start we can name is said with its name; the correlated start, if any, comes with the message itself.
126
- if (binding.turn && turn_id && binding.turn.turn_id === turn_id) return announceWork(binding, true, { turn_phase: 'start', ...correlation() });
127
- return announceWork(binding, true, {});
128
- }
129
- const ours = binding.turn && (!turn_id || !binding.turn.turn_id || binding.turn.turn_id === turn_id);
130
- if (ours) { const extra = binding.turn.turn_id ? { turn_phase: 'end', ...correlation() } : {}; binding.turn = null; return announceWork(binding, false, extra); }
131
- if (turn_id && binding.turn) return; // some other turn ended: ours is still running
132
- return announceWork(binding, false, {});
133
- },
134
- });
135
- keepAnnouncing();
136
- }
137
- function unwatch(binding) { try { binding.stop?.(); } catch {} binding.stop = null; }
138
-
139
- function open() {
140
- if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
141
- const socket = ws = new WebSocket(creds.url);
142
- socket.addEventListener('open', () => {
143
- send({ type: 'connector.hello', protocol: PROTOCOL, connector_id: creds.connector_id, token: creds.token, host: hostId });
144
- });
145
- socket.addEventListener('message', event => { receive(JSON.parse(String(event.data))).catch(error => send({ type: 'connector.error', error: error.message })); });
146
- const lost = () => { if (ws === socket) { ws = null; connected = false; } reconnect(); };
147
- // A refused connection surfaces as 'error' with no 'close', and the dead socket stays
148
- // CONNECTING forever: forget it, or open() would never make another one.
149
- socket.addEventListener('close', lost);
150
- socket.addEventListener('error', lost);
151
- }
152
- function reconnect() {
153
- if (closed || reconnectTimer) return;
154
- const delay = Math.min(10_000, 250 * 2 ** Math.min(reconnectAttempt++, 6));
155
- reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, delay);
156
- }
157
-
158
- async function receive(frame) {
159
- switch (frame.type) {
160
- case 'connector.welcome':
161
- connected = true; reconnectAttempt = 0; lastError = null;
162
- for (const binding of bindings.values()) {
163
- // `local-*` is only a connector-side placeholder while the first
164
- // registration waits for the room to mint its durable binding id.
165
- // Sending it back makes the room correctly reject it as foreign.
166
- const frame = { type: 'binding.register', client_ref: binding.client_ref,
167
- harness: binding.harness, thread: binding.thread, title: binding.title,
168
- inbound: binding.inbound, capabilities: binding.capabilities, focus: false };
169
- if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
170
- send(frame);
171
- }
172
- for (const speech of outbox) send(speech);
173
- return;
174
- case 'heartbeat': send({ type: 'heartbeat.ack', nonce: frame.nonce }); return;
175
- case 'binding.registered': {
176
- const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
177
- 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); }
178
- if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
179
- registering.get(frame.client_ref)?.resolve(frame); return;
180
- }
181
- case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
182
- case 'speech.published': {
183
- outbox = outbox.filter(speech => speech.event_id !== frame.event_id); saveOutbox();
184
- publishing.get(frame.event_id)?.resolve(frame); return;
185
- }
186
- case 'input.deliver': {
187
- const binding = bindings.get(frame.binding_id);
188
- if (!binding) { send({ type: 'input.ack', event_id: frame.event_id, status: 'unknown_binding' }); return; }
189
- // One delivery at a time per binding keeps the user's turns in order.
190
- binding.chain = (binding.chain || Promise.resolve()).then(async () => {
191
- // Expected before it is sent: the harness can take the message, and its transcript show it, before the
192
- // delivery call has even settled (Claude Code admitted one 9 ms after the write; the socket answered
193
- // 1.5 s later, 2026-09-21). A message expected and never taken costs a map entry.
194
- if (binding.pending && frame.message_id) {
195
- binding.pending.set(frame.message_id, { session_id: frame.session_id, revision: frame.revision, at: Date.now() });
196
- while (binding.pending.size > PENDING_MAX) binding.pending.delete(binding.pending.keys().next().value);
197
- }
198
- try {
199
- const harness = harnessFor(binding.harness);
200
- const outcome = await harness.deliver(binding.delivery, frame);
201
- console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
202
- send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
203
- } catch (error) {
204
- binding.pending?.delete(frame.message_id);
205
- console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
206
- send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
207
- }
208
- });
209
- return;
210
- }
211
- case 'binding.close': {
212
- // The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
213
- const binding = bindings.get(frame.binding_id);
214
- if (!binding) return;
215
- bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
216
- closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
217
- console.error(`[sidevoice] room closed voice for ${binding.thread}`);
218
- scheduleExit(); return;
219
- }
220
- case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
221
- }
222
- }
223
-
224
- function snapshot() {
225
- return { host: hostId, version: VERSION, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
226
- bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
227
- ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
228
- }
229
- function scheduleExit() {
230
- if (idleTimer) clearTimeout(idleTimer);
231
- idleTimer = setTimeout(() => { if (clients.size === 0 && bindings.size === 0) shutdown(); }, idleMs);
232
- }
233
- function shutdown() {
234
- closed = true; clearTimeout(reconnectTimer); clearTimeout(idleTimer);
235
- try { ws?.close(); } catch {}
236
- server.close();
237
- try { if (Number(readFileSync(lockPath, 'utf8')) === process.pid) { unlinkSync(socketPath); unlinkSync(lockPath); } } catch {}
238
- process.exit(0);
239
- }
240
-
241
- async function command(client, input) {
242
- const params = input.params || {};
243
- switch (input.method) {
244
- case 'register': {
245
- const { client_ref, harness, thread, title, delivery, inbound, capabilities, engine } = params;
246
- if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
247
- closedByRoom.delete(client_ref); // joining again is the user's explicit request
248
- const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
249
- if (existing) {
250
- Object.assign(existing, { owner: client, delivery, inbound, capabilities });
251
- client.bindings.add(existing);
252
- return { binding_id: existing.binding_id, thread, connected };
253
- }
254
- const local_id = 'local-' + randomUUID();
255
- const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
256
- bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
257
- const frame = await new Promise((resolve, reject) => {
258
- 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);
259
- registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
260
- if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
261
- }).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); unwatch(binding); throw error; });
262
- return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
263
- }
264
- case 'publish': {
265
- const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
266
- if (!binding) throw new Error(closedByRoom.has(params.client_ref) ? 'CLOSED_BY_ROOM' : 'Unknown binding');
267
- const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
268
- session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
269
- outbox.push(speech); saveOutbox();
270
- if (!send(speech)) return { status: 'queued', utterance_id: speech.utterance_id };
271
- const reply = await new Promise(resolve => {
272
- const timer = setTimeout(() => { publishing.delete(speech.event_id); resolve(null); }, 15_000);
273
- publishing.set(speech.event_id, { resolve: f => { clearTimeout(timer); publishing.delete(speech.event_id); resolve(f); } });
274
- });
275
- if (!reply) return { status: 'queued', utterance_id: speech.utterance_id };
276
- const { type, event_id, ...result } = reply;
277
- return result;
278
- }
279
- case 'unregister': {
280
- const binding = bindings.get(params.binding_id);
281
- 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
- scheduleExit(); return snapshot();
283
- }
284
- case 'status': return snapshot();
285
- default: throw new Error('Unknown connector command');
286
- }
287
- }
288
-
289
- function serve(socket) {
290
- const client = { socket, bindings: new Set() };
291
- clients.add(client); clearTimeout(idleTimer);
292
- let buffer = '';
293
- socket.on('data', chunk => {
294
- buffer += chunk;
295
- if (buffer.length > 1 << 20) { socket.destroy(); return; }
296
- let index;
297
- while ((index = buffer.indexOf('\n')) >= 0) {
298
- const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
299
- if (!line.trim()) continue;
300
- let input; try { input = JSON.parse(line); } catch { socket.write(JSON.stringify({ ok: false, error: 'Invalid JSON' }) + '\n'); continue; }
301
- command(client, input).then(result => socket.write(JSON.stringify({ id: input.id, ok: true, result }) + '\n'))
302
- .catch(error => socket.write(JSON.stringify({ id: input.id, ok: false, error: error.message }) + '\n'));
303
- }
304
- });
305
- socket.on('error', () => {});
306
- socket.on('close', () => {
307
- clients.delete(client);
308
- // The façade is gone: so is every conversation it spoke for.
309
- 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
- scheduleExit();
311
- });
312
- }
313
-
314
- creds = credentials();
315
- if (!acquireLock()) process.exit(0);
316
- loadOutbox();
317
- try { unlinkSync(socketPath); } catch {}
318
- const server = net.createServer(serve);
319
- await new Promise((resolve, reject) => server.once('error', reject).listen(socketPath, resolve));
320
- process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
321
- scheduleExit();
322
- open();
@@ -1,227 +0,0 @@
1
- /** What Claude Code will do with a message we post to a session's inbox, decided before we post it.
2
- *
3
- * A session that bypasses permission prompts holds an injected message for its user's approval
4
- * instead of delivering it, and the inbox sends us no receipt to say so — the write looks
5
- * identical either way. So the only honest moment to find out is at voice_connect, from the
6
- * session's own launch flags and settings. Best effort by design: a managed policy layer we
7
- * cannot read could tighten this further, and the result says so rather than pretending.
8
- * Documented at https://code.claude.com/docs/en/cross-session-messaging */
9
- import { execFileSync } from 'node:child_process';
10
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
- import net from 'node:net';
12
- import os from 'node:os';
13
- import path from 'node:path';
14
- import { defineHarness, envelope, SUPPORTED, tailJsonl } from './harness-contract.mjs';
15
-
16
- const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
17
-
18
- function readJson(file) {
19
- try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
20
- }
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
-
44
- /** The pid of the session with this id, from Claude Code's own session registry. */
45
- function sessionPid(sessionId) {
46
- const registry = path.join(configDir(), 'sessions');
47
- let entries = [];
48
- try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
49
- for (const name of entries) {
50
- const record = readJson(path.join(registry, name));
51
- if (record?.sessionId === sessionId) return record.pid ?? null;
52
- }
53
- return null;
54
- }
55
-
56
- /** The launch arguments of a pid, via ps so this works the same on Linux and macOS. */
57
- function launchArgs(pid) {
58
- if (!pid) return '';
59
- try { return execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 4000 }).trim(); }
60
- catch { return ''; }
61
- }
62
-
63
- function flag(args, name) {
64
- const match = args.match(new RegExp(`${name}[= ]('[^']*'|"[^"]*"|\\S+)`));
65
- if (!match) return undefined;
66
- return match[1].replace(/^['"]|['"]$/g, '');
67
- }
68
-
69
- /** `--settings` takes inline JSON or a path to a file; both may carry crossSessionInbound. */
70
- function settingsFromFlag(args) {
71
- const value = flag(args, '--settings');
72
- if (!value) return null;
73
- if (value.trim().startsWith('{')) { try { return JSON.parse(value); } catch { return null; } }
74
- return readJson(value);
75
- }
76
-
77
- const BYPASS_MODES = new Set(['bypassPermissions']);
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
-
89
- /** Will an injected message be delivered to this Claude session, or held for its user? */
90
- export function inspectInbound(sessionId) {
91
- const pid = sessionPid(sessionId);
92
- const args = launchArgs(pid);
93
- const user = readJson(path.join(configDir(), 'settings.json')) || {};
94
- const flagged = settingsFromFlag(args) || {};
95
- const mode = flag(args, '--permission-mode') || user.permissions?.defaultMode || 'default';
96
- // Launch flags beat user settings; a managed policy layer could still tighten either.
97
- const inbound = flagged.crossSessionInbound ?? user.crossSessionInbound;
98
- const bypassing = BYPASS_MODES.has(mode);
99
- if (!bypassing) return { ok: true, mode, crossSessionInbound: inbound ?? null };
100
- if (inbound === 'accept') return { ok: true, mode, crossSessionInbound: inbound };
101
- return {
102
- ok: false,
103
- mode,
104
- crossSessionInbound: inbound ?? null,
105
- reason: inbound === 'refuse'
106
- ? 'This session refuses messages from other local processes (crossSessionInbound is "refuse").'
107
- : 'This session bypasses permission prompts, so Claude Code holds messages from other local '
108
- + 'processes for the user to approve instead of delivering them, and it sends no receipt to '
109
- + 'say so. Voice will appear to be sent and nothing will arrive.',
110
- remedy: inbound === 'refuse'
111
- ? 'Change crossSessionInbound from "refuse" to "accept", or start the session in a permission '
112
- + 'mode that prompts.'
113
- : 'Two ways out. Per session: start it with --settings \'{"crossSessionInbound":"accept"}\'. '
114
- + 'For every session on this machine: add "crossSessionInbound": "accept" to '
115
- + `${path.join(configDir(), 'settings.json')} — that takes effect immediately, releases any `
116
- + 'messages already held, and also lets any other local process post into all your sessions, '
117
- + 'which is the safeguard it removes. Or run the conversation in a prompting mode such as '
118
- + '--permission-mode auto.',
119
- // Said plainly so nothing downstream reports this as certain.
120
- confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
121
- };
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. The read receipt comes from watching the
139
- * session's own transcript (see observe); 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
- /** 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(); };
209
- }
210
-
211
- export const claudeHarness = defineHarness({
212
- name: 'claude',
213
- capabilities: {
214
- deliver: SUPPORTED,
215
- inspectInbound: SUPPORTED,
216
- working: SUPPORTED,
217
- endOfTurn: SUPPORTED,
218
- sessionIdentity: SUPPORTED,
219
- },
220
- deliver,
221
- inspectInbound,
222
- engine: sessionEngine,
223
- observe,
224
- sessionIdentity,
225
- });
226
-
227
- export default claudeHarness;