@sidevoice/uplink 0.2.0 → 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 +19 -10
- package/cli.mjs +7 -4
- package/connector.mjs +114 -17
- package/harness-claude.mjs +140 -1
- package/harness-codex.mjs +126 -0
- package/harness-contract.mjs +116 -0
- package/harness-http.mjs +39 -0
- package/harnesses.mjs +19 -0
- package/install.mjs +151 -0
- package/mcp.mjs +73 -36
- package/package.json +8 -2
- package/pair.mjs +49 -17
- package/skill/voice-room/SKILL.md +16 -0
- package/skill.mjs +57 -0
- package/adapters.mjs +0 -69
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Sidevoice uplink — the client side (`@sidevoice/uplink`)
|
|
2
2
|
|
|
3
|
-
The client side.
|
|
3
|
+
The client side. One bin (`sidevoice`), these entry points:
|
|
4
4
|
|
|
5
|
+
- `install` — registers the MCP server with the harness (re-pinned to this
|
|
6
|
+
version when an older one was registered) and installs the skill. Pairs with
|
|
7
|
+
nothing; reports whether the machine is paired and with which room.
|
|
5
8
|
- `mcp` — the stdio MCP server a harness starts. One per conversation. Exposes
|
|
6
|
-
`voice_connect`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
|
|
9
|
+
`voice_connect`, `voice_pair`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
|
|
7
10
|
operational instructions in its `initialize` result. It never talks to the
|
|
8
11
|
room: it keeps one local connection to the connector for as long as the
|
|
9
12
|
session lives, and the binding it registered dies with that connection.
|
|
@@ -13,17 +16,23 @@ 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
Harness modules implement one contract (`harness-contract.mjs`): delivery,
|
|
25
|
+
inbound inspection, mechanical working state (polled or lifecycle-backed), end-of-turn reporting and session
|
|
26
|
+
identity. Every capability is explicitly `supported` or `unsupported`; an old
|
|
27
|
+
or malformed declaration becomes `unknown`, never false. Claude Code, Codex and
|
|
28
|
+
generic HTTP each have one module. See the repository's
|
|
29
|
+
[`docs/HARNESS_CONTRACT.md`](../../docs/HARNESS_CONTRACT.md).
|
|
23
30
|
|
|
24
31
|
Protocol (newline-free JSON over the WebSocket): `connector.hello` ->
|
|
25
|
-
`connector.welcome`; `binding.register`
|
|
26
|
-
|
|
32
|
+
`connector.welcome`; `binding.register` (including declared harness capabilities)
|
|
33
|
+
-> `binding.registered|rejected`;
|
|
34
|
+
`binding.unregister`; `input.deliver` -> `input.ack`; `input.working`;
|
|
35
|
+
`speech.publish` ->
|
|
27
36
|
`speech.published`; `heartbeat` <-> `heartbeat.ack`. Protocol version 1.
|
|
28
37
|
|
|
29
38
|
Node 22+, no dependencies. Tests: `node --test test/test_connector.mjs`.
|
package/cli.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** `sidevoice <mcp|pair|connector> …` — one bin,
|
|
3
|
-
const [, , command
|
|
4
|
-
const entries = { mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs' };
|
|
5
|
-
if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector> [args]'); process.exit(2); }
|
|
2
|
+
/** `sidevoice <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
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';
|
|
7
10
|
await import(entries[command]);
|
package/connector.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
|
|
11
|
+
import { harnessFor } from './harnesses.mjs';
|
|
11
12
|
|
|
12
13
|
export const PROTOCOL = 1;
|
|
13
14
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
@@ -24,11 +25,12 @@ function credentials() {
|
|
|
24
25
|
const url = process.env.SIDEVOICE_URL || saved.url;
|
|
25
26
|
const connector_id = process.env.SIDEVOICE_CONNECTOR_ID || saved.connector_id;
|
|
26
27
|
const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
|
|
27
|
-
if (!url || !connector_id || !token) throw new Error(`Not paired
|
|
28
|
+
if (!url || !connector_id || !token) throw new Error(`Not paired with any room (looked in ${credentialsPath}): the conversation's voice_pair, or sidevoice pair <room-url> <code>, with the code the room shows`);
|
|
28
29
|
const parsed = new URL(url);
|
|
29
30
|
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
|
|
30
31
|
if (parsed.protocol !== 'wss:' && !loopback) throw new Error('The room URL must be wss:// unless it is loopback');
|
|
31
|
-
|
|
32
|
+
const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
|
|
33
|
+
return { url, connector_id, token, room: room.origin };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
|
|
@@ -46,10 +48,12 @@ function acquireLock() {
|
|
|
46
48
|
return false;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, owner, chain }
|
|
51
|
+
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, capabilities, owner, chain }
|
|
50
52
|
const registering = new Map(); // client_ref -> { resolve, reject, timer }
|
|
51
53
|
const publishing = new Map(); // event_id -> { resolve, timer }
|
|
52
54
|
const clients = new Set(); // façade IPC connections
|
|
55
|
+
const closedByRoom = new Map(); // client_ref -> reason: the user closed that conversation's voice from the room
|
|
56
|
+
const readReported = new Set(); // message ids already reported as read, so a transcript read twice is harmless
|
|
53
57
|
let outbox = []; // speech frames not yet confirmed by the room
|
|
54
58
|
let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
|
|
55
59
|
let creds;
|
|
@@ -61,6 +65,74 @@ function saveOutbox() {
|
|
|
61
65
|
}
|
|
62
66
|
function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
|
|
63
67
|
|
|
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.
|
|
73
|
+
*
|
|
74
|
+
* The connector does not assume the room still knows what it was told. A transition is sent the moment it
|
|
75
|
+
* is seen, and the same state is said again every few seconds regardless: a room that restarted, or a
|
|
76
|
+
* socket that dropped, learns what is true within one interval instead of waiting for the next change that
|
|
77
|
+
* may never come (a conversation that was already working when the room came back showed nothing at all,
|
|
78
|
+
* 2026-09-20). Saying it again is one small frame; not saying it is a light that never comes on. */
|
|
79
|
+
const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
|
|
80
|
+
const PENDING_MAX = 64;
|
|
81
|
+
let announceTimer = null;
|
|
82
|
+
function announceWork(binding, working, extra = {}) {
|
|
83
|
+
binding.working = working;
|
|
84
|
+
binding.workingSentAt = Date.now();
|
|
85
|
+
return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
|
|
86
|
+
}
|
|
87
|
+
function keepAnnouncing() {
|
|
88
|
+
if (announceTimer) return;
|
|
89
|
+
announceTimer = setInterval(() => {
|
|
90
|
+
if (!bindings.size) { clearInterval(announceTimer); announceTimer = null; return; }
|
|
91
|
+
for (const binding of bindings.values()) {
|
|
92
|
+
if (typeof binding.working !== 'boolean') continue;
|
|
93
|
+
if (Date.now() - (binding.workingSentAt || 0) >= WORK_ANNOUNCE_MS) announceWork(binding, binding.working);
|
|
94
|
+
}
|
|
95
|
+
}, Math.max(50, WORK_ANNOUNCE_MS / 4));
|
|
96
|
+
announceTimer.unref?.();
|
|
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; }
|
|
135
|
+
|
|
64
136
|
function open() {
|
|
65
137
|
if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
66
138
|
const socket = ws = new WebSocket(creds.url);
|
|
@@ -90,7 +162,7 @@ async function receive(frame) {
|
|
|
90
162
|
// Sending it back makes the room correctly reject it as foreign.
|
|
91
163
|
const frame = { type: 'binding.register', client_ref: binding.client_ref,
|
|
92
164
|
harness: binding.harness, thread: binding.thread, title: binding.title,
|
|
93
|
-
inbound: binding.inbound, focus: false };
|
|
165
|
+
inbound: binding.inbound, capabilities: binding.capabilities, focus: false };
|
|
94
166
|
if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
|
|
95
167
|
send(frame);
|
|
96
168
|
}
|
|
@@ -100,6 +172,7 @@ async function receive(frame) {
|
|
|
100
172
|
case 'binding.registered': {
|
|
101
173
|
const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
|
|
102
174
|
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); }
|
|
175
|
+
if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
|
|
103
176
|
registering.get(frame.client_ref)?.resolve(frame); return;
|
|
104
177
|
}
|
|
105
178
|
case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
|
|
@@ -112,24 +185,43 @@ async function receive(frame) {
|
|
|
112
185
|
if (!binding) { send({ type: 'input.ack', event_id: frame.event_id, status: 'unknown_binding' }); return; }
|
|
113
186
|
// One delivery at a time per binding keeps the user's turns in order.
|
|
114
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
|
+
}
|
|
115
195
|
try {
|
|
116
|
-
const
|
|
196
|
+
const harness = harnessFor(binding.harness);
|
|
197
|
+
const outcome = await harness.deliver(binding.delivery, frame);
|
|
117
198
|
console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
|
|
118
199
|
send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
|
|
119
200
|
} catch (error) {
|
|
201
|
+
binding.pending?.delete(frame.message_id);
|
|
120
202
|
console.error(`[sidevoice] delivery of ${frame.event_id} failed: ${error.message}`);
|
|
121
203
|
send({ type: 'input.ack', event_id: frame.event_id, status: 'failed', error: String(error.message || error).slice(0, 400) });
|
|
122
204
|
}
|
|
123
205
|
});
|
|
124
206
|
return;
|
|
125
207
|
}
|
|
208
|
+
case 'binding.close': {
|
|
209
|
+
// The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
|
|
210
|
+
const binding = bindings.get(frame.binding_id);
|
|
211
|
+
if (!binding) return;
|
|
212
|
+
bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); unwatch(binding);
|
|
213
|
+
closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
|
|
214
|
+
console.error(`[sidevoice] room closed voice for ${binding.thread}`);
|
|
215
|
+
scheduleExit(); return;
|
|
216
|
+
}
|
|
126
217
|
case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
|
|
127
218
|
}
|
|
128
219
|
}
|
|
129
220
|
|
|
130
221
|
function snapshot() {
|
|
131
|
-
return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError,
|
|
132
|
-
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery
|
|
222
|
+
return { host: hostId, room: creds.room, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
223
|
+
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
|
|
224
|
+
({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
|
|
133
225
|
}
|
|
134
226
|
function scheduleExit() {
|
|
135
227
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -147,23 +239,28 @@ async function command(client, input) {
|
|
|
147
239
|
const params = input.params || {};
|
|
148
240
|
switch (input.method) {
|
|
149
241
|
case 'register': {
|
|
150
|
-
const { client_ref, harness, thread, title, delivery, inbound } = params;
|
|
242
|
+
const { client_ref, harness, thread, title, delivery, inbound, capabilities, engine } = params;
|
|
151
243
|
if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
|
|
244
|
+
closedByRoom.delete(client_ref); // joining again is the user's explicit request
|
|
152
245
|
const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
|
|
153
|
-
if (existing) {
|
|
246
|
+
if (existing) {
|
|
247
|
+
Object.assign(existing, { owner: client, delivery, inbound, capabilities });
|
|
248
|
+
client.bindings.add(existing);
|
|
249
|
+
return { binding_id: existing.binding_id, thread, connected };
|
|
250
|
+
}
|
|
154
251
|
const local_id = 'local-' + randomUUID();
|
|
155
|
-
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, owner: client };
|
|
156
|
-
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open();
|
|
252
|
+
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
|
|
253
|
+
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watch(binding);
|
|
157
254
|
const frame = await new Promise((resolve, reject) => {
|
|
158
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);
|
|
159
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); } });
|
|
160
|
-
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound })) { /* sent on welcome */ }
|
|
161
|
-
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); throw error; });
|
|
257
|
+
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
|
|
258
|
+
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); unwatch(binding); throw error; });
|
|
162
259
|
return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
|
|
163
260
|
}
|
|
164
261
|
case 'publish': {
|
|
165
262
|
const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
|
|
166
|
-
if (!binding) throw new Error('Unknown binding');
|
|
263
|
+
if (!binding) throw new Error(closedByRoom.has(params.client_ref) ? 'CLOSED_BY_ROOM' : 'Unknown binding');
|
|
167
264
|
const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
|
|
168
265
|
session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
|
|
169
266
|
outbox.push(speech); saveOutbox();
|
|
@@ -178,7 +275,7 @@ async function command(client, input) {
|
|
|
178
275
|
}
|
|
179
276
|
case 'unregister': {
|
|
180
277
|
const binding = bindings.get(params.binding_id);
|
|
181
|
-
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 }); }
|
|
182
279
|
scheduleExit(); return snapshot();
|
|
183
280
|
}
|
|
184
281
|
case 'status': return snapshot();
|
|
@@ -206,7 +303,7 @@ function serve(socket) {
|
|
|
206
303
|
socket.on('close', () => {
|
|
207
304
|
clients.delete(client);
|
|
208
305
|
// The façade is gone: so is every conversation it spoke for.
|
|
209
|
-
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 }); }
|
|
210
307
|
scheduleExit();
|
|
211
308
|
});
|
|
212
309
|
}
|
package/harness-claude.mjs
CHANGED
|
@@ -7,9 +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
|
+
import net from 'node:net';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
14
|
+
import { defineHarness, envelope, SUPPORTED, tailJsonl } from './harness-contract.mjs';
|
|
13
15
|
|
|
14
16
|
const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
15
17
|
|
|
@@ -17,6 +19,28 @@ function readJson(file) {
|
|
|
17
19
|
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** The record Claude Code keeps for a session, or null. It publishes `status` there and keeps it current. */
|
|
23
|
+
export function sessionRecord(sessionId) {
|
|
24
|
+
const registry = path.join(configDir(), 'sessions');
|
|
25
|
+
let entries = [];
|
|
26
|
+
try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
|
|
27
|
+
for (const name of entries) {
|
|
28
|
+
const record = readJson(path.join(registry, name));
|
|
29
|
+
if (record?.sessionId === sessionId) return record;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether that session is working on something right now: true, false, or null when it cannot be told.
|
|
35
|
+
* This is Claude Code's own bookkeeping, not a published interface: an unknown value answers null rather
|
|
36
|
+
* than guessing, and the room falls back to what the conversation says about its own replies. */
|
|
37
|
+
export function sessionWorking(sessionId) {
|
|
38
|
+
const status = sessionRecord(sessionId)?.status;
|
|
39
|
+
if (status === 'busy') return true;
|
|
40
|
+
if (status === 'idle' || status === 'ready' || status === 'waiting') return false;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
/** The pid of the session with this id, from Claude Code's own session registry. */
|
|
21
45
|
function sessionPid(sessionId) {
|
|
22
46
|
const registry = path.join(configDir(), 'sessions');
|
|
@@ -52,6 +76,16 @@ function settingsFromFlag(args) {
|
|
|
52
76
|
|
|
53
77
|
const BYPASS_MODES = new Set(['bypassPermissions']);
|
|
54
78
|
|
|
79
|
+
/** Which model this conversation runs, read from the session's own launch line — no model is asked to
|
|
80
|
+
* say what it is. Absent rather than guessed when the launcher did not name one (the CLI's default). */
|
|
81
|
+
export function sessionEngine(sessionId) {
|
|
82
|
+
const args = launchArgs(sessionPid(sessionId));
|
|
83
|
+
if (!args) return null;
|
|
84
|
+
const model = flag(args, '--model'), effort = flag(args, '--effort'), thinking = flag(args, '--thinking');
|
|
85
|
+
if (!model && !effort) return null;
|
|
86
|
+
return { model: model || null, effort: effort || null, thinking: thinking || null };
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
/** Will an injected message be delivered to this Claude session, or held for its user? */
|
|
56
90
|
export function inspectInbound(sessionId) {
|
|
57
91
|
const pid = sessionPid(sessionId);
|
|
@@ -86,3 +120,108 @@ export function inspectInbound(sessionId) {
|
|
|
86
120
|
confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
|
|
87
121
|
};
|
|
88
122
|
}
|
|
123
|
+
|
|
124
|
+
/** Identity and private inbox inherited by the MCP façade Claude Code spawned. */
|
|
125
|
+
export function sessionIdentity({ env = process.env } = {}) {
|
|
126
|
+
if (!env.CLAUDE_CODE_SESSION_ID) return null;
|
|
127
|
+
return {
|
|
128
|
+
harness: 'claude',
|
|
129
|
+
thread: env.CLAUDE_CODE_SESSION_ID,
|
|
130
|
+
...(env.CLAUDE_CODE_MESSAGING_SOCKET ? { delivery: {
|
|
131
|
+
kind: 'claude-uds',
|
|
132
|
+
socket: env.CLAUDE_CODE_MESSAGING_SOCKET,
|
|
133
|
+
token: env.CLAUDE_CODE_MESSAGING_TOKEN || '',
|
|
134
|
+
} } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Claude Code's session inbox sends no acknowledgement. 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;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Codex harness implementation.
|
|
2
|
+
*
|
|
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
|
+
import { execFile } from 'node:child_process';
|
|
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';
|
|
10
|
+
|
|
11
|
+
function turnMetadata(meta) {
|
|
12
|
+
let turn = meta?.['x-codex-turn-metadata'] || {};
|
|
13
|
+
if (typeof turn === 'string') {
|
|
14
|
+
try { turn = JSON.parse(turn); } catch { turn = {}; }
|
|
15
|
+
}
|
|
16
|
+
return turn;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionIdentity({ meta, env = process.env, payload } = {}) {
|
|
20
|
+
const turn = turnMetadata(meta);
|
|
21
|
+
const thread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId
|
|
22
|
+
|| meta?.codex_thread_id || turn.thread_id || payload?.session_id || payload?.thread_id
|
|
23
|
+
|| env.CODEX_THREAD_ID;
|
|
24
|
+
if (!thread) return null;
|
|
25
|
+
const delivery = env.SIDEVOICE_DELIVERY_URL
|
|
26
|
+
? { kind: 'http', url: env.SIDEVOICE_DELIVERY_URL, thread }
|
|
27
|
+
: { kind: 'codex-queue', thread };
|
|
28
|
+
return { harness: 'codex', thread, delivery };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deliver(delivery, event) {
|
|
32
|
+
if (delivery?.kind === 'http') {
|
|
33
|
+
return fetch(delivery.url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
|
|
37
|
+
session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
|
|
38
|
+
signal: AbortSignal.timeout(30_000),
|
|
39
|
+
}).then(async response => {
|
|
40
|
+
if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
|
|
41
|
+
return { status: 'accepted', detail: `receiver answered ${response.status}` };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (delivery?.kind !== 'codex-queue') throw new Error(`Unsupported Codex delivery kind: ${delivery?.kind}`);
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
|
|
47
|
+
const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
|
|
48
|
+
execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
49
|
+
if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
|
|
50
|
+
resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Which model this thread runs, from the launch line of the process that owns it, when it says. */
|
|
56
|
+
function engine(thread, env = process.env) {
|
|
57
|
+
const named = env.CODEX_MODEL || null;
|
|
58
|
+
return named ? { model: named, effort: env.CODEX_REASONING_EFFORT || null, thinking: null } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
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;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What one rollout line says, in the terms of the contract: a working transition, a user message, or nothing. */
|
|
77
|
+
export function interpretRollout(entry, state = {}) {
|
|
78
|
+
const payload = entry?.payload || {};
|
|
79
|
+
if (entry?.type === 'event_msg') {
|
|
80
|
+
if (payload.type === 'task_started' && payload.turn_id) { state.turn_id = payload.turn_id; return { working: true, turn_id: payload.turn_id }; }
|
|
81
|
+
if ((payload.type === 'task_complete' || payload.type === 'turn_aborted') && payload.turn_id) {
|
|
82
|
+
if (state.turn_id === payload.turn_id) state.turn_id = null;
|
|
83
|
+
return { working: false, turn_id: payload.turn_id };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (entry?.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
|
|
88
|
+
const text = (payload.content || []).filter(part => part?.type === 'input_text').map(part => part.text).join('\n');
|
|
89
|
+
return text ? { text, turn_id: state.turn_id || null } : null;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
|
|
95
|
+
|
|
96
|
+
/** Watch one thread through its rollout: task_started/task_complete are the turn, a user message is the
|
|
97
|
+
* moment the thread took it (a queued message is written when the turn starts on it). What the rollout
|
|
98
|
+
* already holds is read first, silently, so a turn that was running before we looked is reported as
|
|
99
|
+
* running — old messages are not re-read. */
|
|
100
|
+
export function observe(threadId, handlers) {
|
|
101
|
+
const state = {};
|
|
102
|
+
let working = null;
|
|
103
|
+
return tailJsonl(() => rolloutPath(threadId), (entry, replayed) => {
|
|
104
|
+
const seen = interpretRollout(entry, state);
|
|
105
|
+
if (!seen) return;
|
|
106
|
+
if (typeof seen.working === 'boolean') { working = seen.working; if (!replayed) handlers.working(seen.working, { turn_id: seen.turn_id }); }
|
|
107
|
+
else if (!replayed) handlers.userMessage({ text: seen.text, turn_id: seen.turn_id });
|
|
108
|
+
}, { intervalMs: POLL_MS, catchUp: true, caughtUp: () => { if (working !== null) handlers.working(working, { turn_id: working ? state.turn_id : null }); } });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const codexHarness = defineHarness({
|
|
112
|
+
name: 'codex',
|
|
113
|
+
capabilities: {
|
|
114
|
+
deliver: SUPPORTED,
|
|
115
|
+
inspectInbound: UNSUPPORTED,
|
|
116
|
+
working: SUPPORTED,
|
|
117
|
+
endOfTurn: SUPPORTED,
|
|
118
|
+
sessionIdentity: SUPPORTED,
|
|
119
|
+
},
|
|
120
|
+
deliver,
|
|
121
|
+
engine,
|
|
122
|
+
observe,
|
|
123
|
+
sessionIdentity,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export default codexHarness;
|