@sidevoice/uplink 0.4.3 → 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.
- package/README.md +24 -12
- package/dist/cli.mjs +10512 -0
- package/dist/package.json +32 -0
- package/package.json +10 -13
- package/cli.mjs +0 -11
- package/connector.mjs +0 -363
- package/harness-claude.mjs +0 -227
- package/harness-codex.mjs +0 -126
- package/harness-contract.mjs +0 -116
- package/harness-http.mjs +0 -39
- package/harnesses.mjs +0 -19
- package/install.mjs +0 -256
- package/mcp.mjs +0 -222
- package/pair.mjs +0 -65
- package/skill.mjs +0 -41
|
@@ -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.
|
|
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,28 +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
|
-
"
|
|
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",
|
|
14
|
+
"dist",
|
|
25
15
|
"README.md"
|
|
26
16
|
],
|
|
27
17
|
"scripts": {
|
|
18
|
+
"build": "node build.mjs",
|
|
19
|
+
"prepack": "npm run build",
|
|
28
20
|
"test": "node --test test/test_connector.mjs"
|
|
29
21
|
},
|
|
30
22
|
"repository": {
|
|
31
23
|
"type": "git",
|
|
32
24
|
"url": "https://github.com/rubasace/sidevoice.git",
|
|
33
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"
|
|
34
31
|
}
|
|
35
32
|
}
|
package/cli.mjs
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** `sidevoice <install|uninstall|mcp|pair|connector|skill> …` — one bin, six entry points. */
|
|
3
|
-
const [, , command] = process.argv;
|
|
4
|
-
const entries = { install: './install.mjs', uninstall: './install.mjs', mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', skill: './skill.mjs' };
|
|
5
|
-
if (!entries[command]) { console.error('usage: sidevoice <install|uninstall|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
|
-
if (command === 'uninstall') process.env.SIDEVOICE_UNINSTALL_MAIN = '1';
|
|
11
|
-
await import(entries[command]);
|
package/connector.mjs
DELETED
|
@@ -1,363 +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 { appendFileSync, mkdirSync, openSync, closeSync, statSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
9
|
-
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
11
|
-
import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
|
|
12
|
-
import { harnessFor } from './harnesses.mjs';
|
|
13
|
-
import { privateNetwork } from './pair.mjs';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
|
|
16
|
-
const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
|
|
17
|
-
|
|
18
|
-
export const PROTOCOL = 1;
|
|
19
|
-
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
20
|
-
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
21
|
-
const lockPath = socketPath + '.lock';
|
|
22
|
-
const outboxPath = path.join(dataDir, 'outbox.json');
|
|
23
|
-
const credentialsPath = process.env.SIDEVOICE_CREDENTIALS || path.join(dataDir, 'credentials.json');
|
|
24
|
-
const idleMs = Number(process.env.SIDEVOICE_CONNECTOR_IDLE_MS || 15_000);
|
|
25
|
-
const hostId = process.env.SIDEVOICE_HOST_ID || os.hostname();
|
|
26
|
-
const logPath = process.env.SIDEVOICE_CONNECTOR_LOG || path.join(dataDir, 'connector.log');
|
|
27
|
-
const LOG_MAX = 1 << 20;
|
|
28
|
-
|
|
29
|
-
/** One line per event, to stderr and to `connector.log` in the data dir: the façade starts this process
|
|
30
|
-
* with its output discarded, so the file is the only record of a connector nobody ran by hand. Rolls
|
|
31
|
-
* over once, at 1 MB. */
|
|
32
|
-
function log(line) {
|
|
33
|
-
const stamped = `${new Date().toISOString()} [sidevoice] ${line}`;
|
|
34
|
-
console.error(stamped);
|
|
35
|
-
try {
|
|
36
|
-
let size = 0; try { size = statSync(logPath).size; } catch {}
|
|
37
|
-
if (size > LOG_MAX) renameSync(logPath, logPath + '.1');
|
|
38
|
-
appendFileSync(logPath, stamped + '\n', { mode: 0o600 });
|
|
39
|
-
} catch {}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function credentials() {
|
|
43
|
-
let saved = {};
|
|
44
|
-
try { saved = JSON.parse(readFileSync(credentialsPath, 'utf8')); } catch {}
|
|
45
|
-
const url = process.env.SIDEVOICE_URL || saved.url;
|
|
46
|
-
const connector_id = process.env.SIDEVOICE_CONNECTOR_ID || saved.connector_id;
|
|
47
|
-
const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
|
|
48
|
-
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`);
|
|
49
|
-
const parsed = new URL(url);
|
|
50
|
-
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');
|
|
51
|
-
const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
|
|
52
|
-
return { url, connector_id, token, room: room.origin };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Whether the pid in the lock is a live Sidevoice connector — not merely a live pid. Pids are reused,
|
|
56
|
-
* and on macOS a pid that now belongs to another user answers EPERM, which used to count as alive: a
|
|
57
|
-
* stale lock then made every new connector exit at once, silently (a laptop, 2026-09-21). */
|
|
58
|
-
function connectorAlive(pid) {
|
|
59
|
-
try { process.kill(pid, 0); } catch (error) { if (error.code !== 'EPERM') return false; }
|
|
60
|
-
try {
|
|
61
|
-
const args = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim();
|
|
62
|
-
return /(^|[\s/])connector(\.mjs)?(\s|$)/.test(args); // `…/connector.mjs` from a checkout, `sidevoice connector` from a package; not test_connector.mjs
|
|
63
|
-
} catch { return true; } // No ps to ask: a live pid is taken at its word.
|
|
64
|
-
}
|
|
65
|
-
function acquireLock() {
|
|
66
|
-
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
67
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
68
|
-
try { const fd = openSync(lockPath, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd); return true; }
|
|
69
|
-
catch (error) {
|
|
70
|
-
if (error.code !== 'EEXIST') throw error;
|
|
71
|
-
let pid = 0; try { pid = Number(readFileSync(lockPath, 'utf8')); } catch {}
|
|
72
|
-
if (pid && connectorAlive(pid)) { // A live connector holds it: we are redundant, and we say so.
|
|
73
|
-
log(`a connector is already running (pid ${pid}, lock ${lockPath}); this one exits`);
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
log(`stale lock ${lockPath} (pid ${pid || '?'} is not a connector); taking over`);
|
|
77
|
-
try { unlinkSync(lockPath); } catch {}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, capabilities, owner, chain }
|
|
84
|
-
const registering = new Map(); // client_ref -> { resolve, reject, timer }
|
|
85
|
-
const publishing = new Map(); // event_id -> { resolve, timer }
|
|
86
|
-
const clients = new Set(); // façade IPC connections
|
|
87
|
-
const closedByRoom = new Map(); // client_ref -> reason: the user closed that conversation's voice from the room
|
|
88
|
-
const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
|
|
89
|
-
let outbox = []; // speech frames not yet confirmed by the room
|
|
90
|
-
let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
|
|
91
|
-
let socketError = null; // why the last attempt to reach the room failed, for whoever asks status
|
|
92
|
-
let creds;
|
|
93
|
-
|
|
94
|
-
function loadOutbox() { try { outbox = JSON.parse(readFileSync(outboxPath, 'utf8')); if (!Array.isArray(outbox)) outbox = []; } catch { outbox = []; } }
|
|
95
|
-
function saveOutbox() {
|
|
96
|
-
const temporary = outboxPath + '.' + process.pid + '.tmp';
|
|
97
|
-
writeFileSync(temporary, JSON.stringify(outbox), { mode: 0o600 }); renameSync(temporary, outboxPath);
|
|
98
|
-
}
|
|
99
|
-
function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
|
|
100
|
-
|
|
101
|
-
/* Whether a conversation is working, and whether it has read what the room sent, are the harness's own
|
|
102
|
-
* state — and every harness writes that state down somewhere of its own: Claude Code in a session registry
|
|
103
|
-
* and a transcript, Codex in the thread's rollout. Each module watches what its harness writes (`observe`)
|
|
104
|
-
* and calls back; nothing is installed in the harness for it. Unknown or unsupported is skipped rather
|
|
105
|
-
* than rendered as false.
|
|
106
|
-
*
|
|
107
|
-
* The connector does not assume the room still knows what it was told. A transition is sent the moment it
|
|
108
|
-
* is seen, and the same state is said again every few seconds regardless: a room that restarted, or a
|
|
109
|
-
* socket that dropped, learns what is true within one interval instead of waiting for the next change that
|
|
110
|
-
* may never come (a conversation that was already working when the room came back showed nothing at all,
|
|
111
|
-
* 2026-09-20). Saying it again is one small frame; not saying it is a light that never comes on. */
|
|
112
|
-
const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
|
|
113
|
-
const PENDING_MAX = 64;
|
|
114
|
-
let announceTimer = null;
|
|
115
|
-
function announceWork(binding, working, extra = {}) {
|
|
116
|
-
binding.working = working;
|
|
117
|
-
binding.workingSentAt = Date.now();
|
|
118
|
-
return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
|
|
119
|
-
}
|
|
120
|
-
function keepAnnouncing() {
|
|
121
|
-
if (announceTimer) return;
|
|
122
|
-
announceTimer = setInterval(() => {
|
|
123
|
-
if (!bindings.size) { clearInterval(announceTimer); announceTimer = null; return; }
|
|
124
|
-
for (const binding of bindings.values()) {
|
|
125
|
-
if (typeof binding.working !== 'boolean') continue;
|
|
126
|
-
if (Date.now() - (binding.workingSentAt || 0) >= WORK_ANNOUNCE_MS) announceWork(binding, binding.working);
|
|
127
|
-
}
|
|
128
|
-
}, Math.max(50, WORK_ANNOUNCE_MS / 4));
|
|
129
|
-
announceTimer.unref?.();
|
|
130
|
-
}
|
|
131
|
-
/** Start watching a binding's conversation through its harness. What we delivered and it has not yet taken
|
|
132
|
-
* waits in `pending`; the moment its transcript shows the message, the room gets the second tick and a
|
|
133
|
-
* correlated start of turn; the end of that turn carries the same correlation. */
|
|
134
|
-
function watch(binding) {
|
|
135
|
-
const harness = harnessFor(binding.harness);
|
|
136
|
-
if (binding.stop || capabilityState(harness, 'working') !== SUPPORTED || typeof harness.observe !== 'function') return;
|
|
137
|
-
binding.pending ||= new Map();
|
|
138
|
-
const correlation = () => binding.turn ? { turn_id: binding.turn.turn_id, session_id: binding.turn.session_id, revision: binding.turn.revision } : {};
|
|
139
|
-
binding.stop = harness.observe(binding.thread, {
|
|
140
|
-
userMessage({ text, turn_id }) {
|
|
141
|
-
const header = voiceEnvelope(text);
|
|
142
|
-
if (!header || !binding.pending.has(header.message_id)) return;
|
|
143
|
-
binding.pending.delete(header.message_id);
|
|
144
|
-
if (!readReported.has(header.message_id)) {
|
|
145
|
-
readReported.add(header.message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
|
|
146
|
-
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 });
|
|
147
|
-
log(`${binding.thread} read ${header.message_id} (session ${header.session_id} rev ${header.revision}, turn ${turn_id || '?'})`);
|
|
148
|
-
}
|
|
149
|
-
if (header.channel !== 'voice') return;
|
|
150
|
-
binding.turn = { turn_id: turn_id || null, session_id: header.session_id, revision: header.revision };
|
|
151
|
-
announceWork(binding, true, turn_id ? { turn_phase: 'start', ...correlation() } : {});
|
|
152
|
-
},
|
|
153
|
-
working(working, { turn_id } = {}) {
|
|
154
|
-
if (working) {
|
|
155
|
-
// A start we can name is said with its name; the correlated start, if any, comes with the message itself.
|
|
156
|
-
if (binding.turn && turn_id && binding.turn.turn_id === turn_id) return announceWork(binding, true, { turn_phase: 'start', ...correlation() });
|
|
157
|
-
return announceWork(binding, true, {});
|
|
158
|
-
}
|
|
159
|
-
const ours = binding.turn && (!turn_id || !binding.turn.turn_id || binding.turn.turn_id === turn_id);
|
|
160
|
-
if (ours) { const extra = binding.turn.turn_id ? { turn_phase: 'end', ...correlation() } : {}; binding.turn = null; return announceWork(binding, false, extra); }
|
|
161
|
-
if (turn_id && binding.turn) return; // some other turn ended: ours is still running
|
|
162
|
-
return announceWork(binding, false, {});
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
keepAnnouncing();
|
|
166
|
-
}
|
|
167
|
-
function unwatch(binding) { try { binding.stop?.(); } catch {} binding.stop = null; }
|
|
168
|
-
|
|
169
|
-
function open() {
|
|
170
|
-
if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
171
|
-
const socket = ws = new WebSocket(creds.url);
|
|
172
|
-
socket.addEventListener('open', () => {
|
|
173
|
-
send({ type: 'connector.hello', protocol: PROTOCOL, connector_id: creds.connector_id, token: creds.token, host: hostId });
|
|
174
|
-
});
|
|
175
|
-
socket.addEventListener('message', event => { receive(JSON.parse(String(event.data))).catch(error => send({ type: 'connector.error', error: error.message })); });
|
|
176
|
-
const lost = why => { if (ws === socket) { ws = null; connected = false; } if (why) { socketError = { ...why, at: new Date().toISOString(), attempt: reconnectAttempt }; log('room unreachable: ' + JSON.stringify(why)); } reconnect(); };
|
|
177
|
-
// A refused connection surfaces as 'error' with no 'close', and the dead socket stays
|
|
178
|
-
// CONNECTING forever: forget it, or open() would never make another one. Whatever the runtime
|
|
179
|
-
// says about it is kept: "not reachable" alone told a person nothing (2026-09-21).
|
|
180
|
-
socket.addEventListener('close', event => lost(connected ? null : { close_code: event.code, reason: event.reason || null }));
|
|
181
|
-
socket.addEventListener('error', event => lost({ error: event.error?.message || event.message || 'connection failed' }));
|
|
182
|
-
}
|
|
183
|
-
function reconnect() {
|
|
184
|
-
if (closed || reconnectTimer) return;
|
|
185
|
-
const delay = Math.min(10_000, 250 * 2 ** Math.min(reconnectAttempt++, 6));
|
|
186
|
-
if (reconnectAttempt <= 3 || reconnectAttempt % 10 === 0) log(`room not connected; retrying in ${delay} ms (attempt ${reconnectAttempt})`);
|
|
187
|
-
reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, delay);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function receive(frame) {
|
|
191
|
-
switch (frame.type) {
|
|
192
|
-
case 'connector.welcome':
|
|
193
|
-
connected = true; reconnectAttempt = 0; lastError = null; socketError = null;
|
|
194
|
-
log(`connected to ${creds.room} as ${creds.connector_id} (protocol ${frame.protocol ?? PROTOCOL}); ${bindings.size} binding(s) to re-register, ${outbox.length} queued speech`);
|
|
195
|
-
for (const binding of bindings.values()) {
|
|
196
|
-
// `local-*` is only a connector-side placeholder while the first
|
|
197
|
-
// registration waits for the room to mint its durable binding id.
|
|
198
|
-
// Sending it back makes the room correctly reject it as foreign.
|
|
199
|
-
const frame = { type: 'binding.register', client_ref: binding.client_ref,
|
|
200
|
-
harness: binding.harness, thread: binding.thread, title: binding.title,
|
|
201
|
-
inbound: binding.inbound, capabilities: binding.capabilities, focus: false };
|
|
202
|
-
if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
|
|
203
|
-
send(frame);
|
|
204
|
-
}
|
|
205
|
-
for (const speech of outbox) send(speech);
|
|
206
|
-
return;
|
|
207
|
-
case 'heartbeat': send({ type: 'heartbeat.ack', nonce: frame.nonce }); return;
|
|
208
|
-
case 'binding.registered': {
|
|
209
|
-
const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
|
|
210
|
-
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); }
|
|
211
|
-
if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
|
|
212
|
-
if (binding) log(`room registered ${binding.thread} as ${frame.binding_id} ("${binding.title || ''}")`);
|
|
213
|
-
registering.get(frame.client_ref)?.resolve(frame); return;
|
|
214
|
-
}
|
|
215
|
-
case 'binding.rejected': log(`room rejected ${frame.client_ref}: ${frame.error || 'no reason'}`); registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
|
|
216
|
-
case 'speech.published': {
|
|
217
|
-
log(`speech ${frame.utterance_id || frame.event_id} ${frame.status || 'published'}${frame.reason ? ' (' + frame.reason + ')' : ''}`);
|
|
218
|
-
outbox = outbox.filter(speech => speech.event_id !== frame.event_id); saveOutbox();
|
|
219
|
-
publishing.get(frame.event_id)?.resolve(frame); return;
|
|
220
|
-
}
|
|
221
|
-
case 'input.deliver': {
|
|
222
|
-
const binding = bindings.get(frame.binding_id);
|
|
223
|
-
if (!binding) { send({ type: 'input.ack', event_id: frame.event_id, status: 'unknown_binding' }); return; }
|
|
224
|
-
// One delivery at a time per binding keeps the user's turns in order.
|
|
225
|
-
binding.chain = (binding.chain || Promise.resolve()).then(async () => {
|
|
226
|
-
// Expected before it is sent: the harness can take the message, and its transcript show it, before the
|
|
227
|
-
// delivery call has even settled (Claude Code admitted one 9 ms after the write; the socket answered
|
|
228
|
-
// 1.5 s later, 2026-09-21). A message expected and never taken costs a map entry.
|
|
229
|
-
if (binding.pending && frame.message_id) {
|
|
230
|
-
binding.pending.set(frame.message_id, { session_id: frame.session_id, revision: frame.revision, at: Date.now() });
|
|
231
|
-
while (binding.pending.size > PENDING_MAX) binding.pending.delete(binding.pending.keys().next().value);
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
const harness = harnessFor(binding.harness);
|
|
235
|
-
const outcome = await harness.deliver(binding.delivery, frame);
|
|
236
|
-
log(`delivered ${frame.event_id} (${frame.message_id}) to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
|
|
237
|
-
send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
|
|
238
|
-
} catch (error) {
|
|
239
|
-
binding.pending?.delete(frame.message_id);
|
|
240
|
-
log(`delivery of ${frame.event_id} to ${binding.thread} failed: ${error.message}`);
|
|
241
|
-
send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
case 'binding.close': {
|
|
247
|
-
// The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
|
|
248
|
-
const binding = bindings.get(frame.binding_id);
|
|
249
|
-
if (!binding) return;
|
|
250
|
-
bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
|
|
251
|
-
closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
|
|
252
|
-
log(`room closed voice for ${binding.thread} (${frame.reason || 'closed_from_room'})`);
|
|
253
|
-
scheduleExit(); return;
|
|
254
|
-
}
|
|
255
|
-
case 'connector.error': lastError = frame.error; log('room says: ' + frame.error); return;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function snapshot() {
|
|
260
|
-
return { host: hostId, version: VERSION, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, socket_error: socketError, closed_by_room: [...closedByRoom.keys()],
|
|
261
|
-
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
|
|
262
|
-
({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
|
|
263
|
-
}
|
|
264
|
-
function scheduleExit() {
|
|
265
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
266
|
-
idleTimer = setTimeout(() => { if (clients.size === 0 && bindings.size === 0) { log(`idle for ${idleMs} ms with no conversation; exiting`); shutdown(); } }, idleMs);
|
|
267
|
-
}
|
|
268
|
-
function shutdown() {
|
|
269
|
-
if (!closed) log(`shutting down (${bindings.size} binding(s), ${clients.size} façade(s))`);
|
|
270
|
-
closed = true; clearTimeout(reconnectTimer); clearTimeout(idleTimer);
|
|
271
|
-
try { ws?.close(); } catch {}
|
|
272
|
-
server.close();
|
|
273
|
-
try { if (Number(readFileSync(lockPath, 'utf8')) === process.pid) { unlinkSync(socketPath); unlinkSync(lockPath); } } catch {}
|
|
274
|
-
process.exit(0);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async function command(client, input) {
|
|
278
|
-
const params = input.params || {};
|
|
279
|
-
switch (input.method) {
|
|
280
|
-
case 'register': {
|
|
281
|
-
const { client_ref, harness, thread, title, delivery, inbound, capabilities, engine } = params;
|
|
282
|
-
if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
|
|
283
|
-
closedByRoom.delete(client_ref); // joining again is the user's explicit request
|
|
284
|
-
const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
|
|
285
|
-
if (existing) {
|
|
286
|
-
Object.assign(existing, { owner: client, delivery, inbound, capabilities });
|
|
287
|
-
client.bindings.add(existing);
|
|
288
|
-
return { binding_id: existing.binding_id, thread, connected };
|
|
289
|
-
}
|
|
290
|
-
const local_id = 'local-' + randomUUID();
|
|
291
|
-
log(`${harness} ${thread} joins ("${title || ''}", delivery ${delivery.kind}, inbound ${inbound ? (inbound.ok ? 'ok' : 'held') : 'n/a'})`);
|
|
292
|
-
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
|
|
293
|
-
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
|
|
294
|
-
const frame = await new Promise((resolve, reject) => {
|
|
295
|
-
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);
|
|
296
|
-
registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
|
|
297
|
-
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
|
|
298
|
-
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); unwatch(binding); throw error; });
|
|
299
|
-
return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
|
|
300
|
-
}
|
|
301
|
-
case 'publish': {
|
|
302
|
-
const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
|
|
303
|
-
if (!binding) throw new Error(closedByRoom.has(params.client_ref) ? 'CLOSED_BY_ROOM' : 'Unknown binding');
|
|
304
|
-
const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
|
|
305
|
-
session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
|
|
306
|
-
outbox.push(speech); saveOutbox();
|
|
307
|
-
if (!send(speech)) return { status: 'queued', utterance_id: speech.utterance_id };
|
|
308
|
-
const reply = await new Promise(resolve => {
|
|
309
|
-
const timer = setTimeout(() => { publishing.delete(speech.event_id); resolve(null); }, 15_000);
|
|
310
|
-
publishing.set(speech.event_id, { resolve: f => { clearTimeout(timer); publishing.delete(speech.event_id); resolve(f); } });
|
|
311
|
-
});
|
|
312
|
-
if (!reply) return { status: 'queued', utterance_id: speech.utterance_id };
|
|
313
|
-
const { type, event_id, ...result } = reply;
|
|
314
|
-
return result;
|
|
315
|
-
}
|
|
316
|
-
case 'unregister': {
|
|
317
|
-
const binding = bindings.get(params.binding_id);
|
|
318
|
-
if (binding) { log(`${binding.thread} leaves`); 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 }); }
|
|
319
|
-
scheduleExit(); return snapshot();
|
|
320
|
-
}
|
|
321
|
-
case 'status': return snapshot();
|
|
322
|
-
default: throw new Error('Unknown connector command');
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function serve(socket) {
|
|
327
|
-
const client = { socket, bindings: new Set() };
|
|
328
|
-
clients.add(client); clearTimeout(idleTimer);
|
|
329
|
-
log(`façade attached (${clients.size} now)`);
|
|
330
|
-
let buffer = '';
|
|
331
|
-
socket.on('data', chunk => {
|
|
332
|
-
buffer += chunk;
|
|
333
|
-
if (buffer.length > 1 << 20) { socket.destroy(); return; }
|
|
334
|
-
let index;
|
|
335
|
-
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
336
|
-
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
337
|
-
if (!line.trim()) continue;
|
|
338
|
-
let input; try { input = JSON.parse(line); } catch { socket.write(JSON.stringify({ ok: false, error: 'Invalid JSON' }) + '\n'); continue; }
|
|
339
|
-
command(client, input).then(result => socket.write(JSON.stringify({ id: input.id, ok: true, result }) + '\n'))
|
|
340
|
-
.catch(error => socket.write(JSON.stringify({ id: input.id, ok: false, error: error.message }) + '\n'));
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
socket.on('error', () => {});
|
|
344
|
-
socket.on('close', () => {
|
|
345
|
-
clients.delete(client);
|
|
346
|
-
log(`façade detached (${clients.size} left); dropping ${client.bindings.size} binding(s)`);
|
|
347
|
-
// The façade is gone: so is every conversation it spoke for.
|
|
348
|
-
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 }); }
|
|
349
|
-
scheduleExit();
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
creds = credentials();
|
|
354
|
-
if (!acquireLock()) process.exit(0);
|
|
355
|
-
log(`connector ${VERSION} starting: pid ${process.pid}, host ${hostId}, room ${creds.room}, socket ${socketPath}, log ${logPath}`);
|
|
356
|
-
loadOutbox();
|
|
357
|
-
if (outbox.length) log(`${outbox.length} speech frame(s) waiting in the outbox`);
|
|
358
|
-
try { unlinkSync(socketPath); } catch {}
|
|
359
|
-
const server = net.createServer(serve);
|
|
360
|
-
await new Promise((resolve, reject) => server.once('error', reject).listen(socketPath, resolve));
|
|
361
|
-
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
|
|
362
|
-
scheduleExit();
|
|
363
|
-
open();
|