@sidevoice/uplink 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/cli.mjs +6 -4
- package/connector.mjs +112 -12
- package/harness-claude.mjs +101 -0
- package/harness-codex.mjs +95 -0
- package/harness-contract.mjs +53 -0
- package/harness-http.mjs +39 -0
- package/harnesses.mjs +36 -0
- package/hook.mjs +119 -0
- package/mcp.mjs +37 -31
- package/package.json +8 -2
- package/skill/voice-room/SKILL.md +31 -0
- package/skill.mjs +57 -0
- package/adapters.mjs +0 -69
package/README.md
CHANGED
|
@@ -16,14 +16,18 @@ The client side. Three entry points behind one bin (`sidevoice`):
|
|
|
16
16
|
- `pair` — redeems a pairing code from the room UI for this machine's
|
|
17
17
|
credential (`~/.sidevoice/credentials.json`, mode 0600).
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
Harness modules implement one contract (`harness-contract.mjs`): delivery,
|
|
20
|
+
inbound inspection, mechanical working state (polled or lifecycle-backed), end-of-turn reporting and session
|
|
21
|
+
identity. Every capability is explicitly `supported` or `unsupported`; an old
|
|
22
|
+
or malformed declaration becomes `unknown`, never false. Claude Code, Codex and
|
|
23
|
+
generic HTTP each have one module. See the repository's
|
|
24
|
+
[`docs/HARNESS_CONTRACT.md`](../../docs/HARNESS_CONTRACT.md).
|
|
23
25
|
|
|
24
26
|
Protocol (newline-free JSON over the WebSocket): `connector.hello` ->
|
|
25
|
-
`connector.welcome`; `binding.register`
|
|
26
|
-
|
|
27
|
+
`connector.welcome`; `binding.register` (including declared harness capabilities)
|
|
28
|
+
-> `binding.registered|rejected`;
|
|
29
|
+
`binding.unregister`; `input.deliver` -> `input.ack`; `input.working`;
|
|
30
|
+
`speech.publish` ->
|
|
27
31
|
`speech.published`; `heartbeat` <-> `heartbeat.ack`. Protocol version 1.
|
|
28
32
|
|
|
29
33
|
Node 22+, no dependencies. Tests: `node --test test/test_connector.mjs`.
|
package/cli.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** `sidevoice <mcp|pair|connector> …` — one bin,
|
|
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 <mcp|pair|connector|hook|skill> …` — one bin, five entry points. */
|
|
3
|
+
const [, , command] = process.argv;
|
|
4
|
+
const entries = { mcp: './mcp.mjs', pair: './pair.mjs', connector: './connector.mjs', hook: './hook.mjs', skill: './skill.mjs' };
|
|
5
|
+
if (!entries[command]) { console.error('usage: sidevoice <mcp|pair|connector|hook|skill> [args]'); process.exit(2); }
|
|
6
6
|
process.argv.splice(2, 1);
|
|
7
|
+
if (command === 'hook') process.env.SIDEVOICE_HOOK_MAIN = '1';
|
|
8
|
+
if (command === 'skill') process.env.SIDEVOICE_SKILL_MAIN = '1';
|
|
7
9
|
await import(entries[command]);
|
package/connector.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { capabilityState, SUPPORTED, WORKING_POLL } from './harness-contract.mjs';
|
|
11
|
+
import { harnessFor } from './harnesses.mjs';
|
|
11
12
|
|
|
12
13
|
export const PROTOCOL = 1;
|
|
13
14
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
@@ -46,10 +47,12 @@ function acquireLock() {
|
|
|
46
47
|
return false;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, owner, chain }
|
|
50
|
+
const bindings = new Map(); // binding_id -> { binding_id, client_ref, harness, thread, title, delivery, capabilities, owner, chain }
|
|
50
51
|
const registering = new Map(); // client_ref -> { resolve, reject, timer }
|
|
51
52
|
const publishing = new Map(); // event_id -> { resolve, timer }
|
|
52
53
|
const clients = new Set(); // façade IPC connections
|
|
54
|
+
const closedByRoom = new Map(); // client_ref -> reason: the user closed that conversation's voice from the room
|
|
55
|
+
const readReported = new Set(); // message ids already reported as read, so a hook that fires twice is harmless
|
|
53
56
|
let outbox = []; // speech frames not yet confirmed by the room
|
|
54
57
|
let ws = null, connected = false, closed = false, reconnectTimer = null, idleTimer = null, reconnectAttempt = 0, lastError = null;
|
|
55
58
|
let creds;
|
|
@@ -61,6 +64,44 @@ function saveOutbox() {
|
|
|
61
64
|
}
|
|
62
65
|
function send(frame) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)); return true; } return false; }
|
|
63
66
|
|
|
67
|
+
/* Whether a conversation is working is the harness's own state: each module answers in the way it can
|
|
68
|
+
* (a polled registry, a stream of lifecycle events), and unknown or unsupported is skipped rather than
|
|
69
|
+
* rendered as false.
|
|
70
|
+
*
|
|
71
|
+
* The connector does not assume the room still knows what it was told. A transition is sent the moment it
|
|
72
|
+
* happens, and the same state is said again every few seconds regardless: a room that restarted, or a
|
|
73
|
+
* socket that dropped, learns what is true within one interval instead of waiting for the next change that
|
|
74
|
+
* may never come (a conversation that was already working when the room came back showed nothing at all,
|
|
75
|
+
* 2026-09-20). Saying it again is one small frame; not saying it is a light that never comes on. */
|
|
76
|
+
const WORK_POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
|
|
77
|
+
const WORK_ANNOUNCE_MS = Number(process.env.SIDEVOICE_WORK_ANNOUNCE_MS || 2000);
|
|
78
|
+
let workTimer = null;
|
|
79
|
+
function announceWork(binding, working, extra = {}) {
|
|
80
|
+
binding.working = working;
|
|
81
|
+
binding.workingSentAt = Date.now();
|
|
82
|
+
return send({ type: 'input.working', binding_id: binding.binding_id, working, ...extra });
|
|
83
|
+
}
|
|
84
|
+
function watchWork() {
|
|
85
|
+
if (workTimer) return;
|
|
86
|
+
workTimer = setInterval(() => {
|
|
87
|
+
if (!bindings.size) { clearInterval(workTimer); workTimer = null; return; }
|
|
88
|
+
for (const binding of bindings.values()) {
|
|
89
|
+
const harness = harnessFor(binding.harness);
|
|
90
|
+
if (capabilityState(harness, 'working') !== SUPPORTED) continue;
|
|
91
|
+
let working = binding.working;
|
|
92
|
+
if (harness.workingSource === WORKING_POLL) {
|
|
93
|
+
const polled = harness.working(binding.client_ref);
|
|
94
|
+
if (polled === null) continue;
|
|
95
|
+
working = polled;
|
|
96
|
+
} else if (typeof working !== 'boolean') continue;
|
|
97
|
+
const due = !binding.workingSentAt || Date.now() - binding.workingSentAt >= WORK_ANNOUNCE_MS;
|
|
98
|
+
if (working === binding.working && !due) continue;
|
|
99
|
+
announceWork(binding, working);
|
|
100
|
+
}
|
|
101
|
+
}, WORK_POLL_MS);
|
|
102
|
+
workTimer.unref?.();
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
function open() {
|
|
65
106
|
if (closed || ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
66
107
|
const socket = ws = new WebSocket(creds.url);
|
|
@@ -90,7 +131,7 @@ async function receive(frame) {
|
|
|
90
131
|
// Sending it back makes the room correctly reject it as foreign.
|
|
91
132
|
const frame = { type: 'binding.register', client_ref: binding.client_ref,
|
|
92
133
|
harness: binding.harness, thread: binding.thread, title: binding.title,
|
|
93
|
-
inbound: binding.inbound, focus: false };
|
|
134
|
+
inbound: binding.inbound, capabilities: binding.capabilities, focus: false };
|
|
94
135
|
if (!binding.binding_id.startsWith('local-')) frame.binding_id = binding.binding_id;
|
|
95
136
|
send(frame);
|
|
96
137
|
}
|
|
@@ -100,6 +141,7 @@ async function receive(frame) {
|
|
|
100
141
|
case 'binding.registered': {
|
|
101
142
|
const binding = [...bindings.values()].find(b => b.client_ref === frame.client_ref);
|
|
102
143
|
if (binding && binding.binding_id !== frame.binding_id) { bindings.delete(binding.binding_id); binding.binding_id = frame.binding_id; bindings.set(frame.binding_id, binding); }
|
|
144
|
+
if (binding && typeof binding.working === 'boolean') announceWork(binding, binding.working);
|
|
103
145
|
registering.get(frame.client_ref)?.resolve(frame); return;
|
|
104
146
|
}
|
|
105
147
|
case 'binding.rejected': registering.get(frame.client_ref)?.reject(new Error(frame.error || 'Binding rejected')); return;
|
|
@@ -113,7 +155,8 @@ async function receive(frame) {
|
|
|
113
155
|
// One delivery at a time per binding keeps the user's turns in order.
|
|
114
156
|
binding.chain = (binding.chain || Promise.resolve()).then(async () => {
|
|
115
157
|
try {
|
|
116
|
-
const
|
|
158
|
+
const harness = harnessFor(binding.harness);
|
|
159
|
+
const outcome = await harness.deliver(binding.delivery, frame);
|
|
117
160
|
console.error(`[sidevoice] delivered ${frame.event_id} to ${binding.thread} via ${binding.delivery.kind}: ${outcome.status} (${outcome.detail})`);
|
|
118
161
|
send({ type: 'input.ack', event_id: frame.event_id, status: outcome.status, detail: outcome.detail });
|
|
119
162
|
} catch (error) {
|
|
@@ -123,13 +166,23 @@ async function receive(frame) {
|
|
|
123
166
|
});
|
|
124
167
|
return;
|
|
125
168
|
}
|
|
169
|
+
case 'binding.close': {
|
|
170
|
+
// The user closed this conversation's voice in the room. Forget the binding; the façade learns it on its next call.
|
|
171
|
+
const binding = bindings.get(frame.binding_id);
|
|
172
|
+
if (!binding) return;
|
|
173
|
+
bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding);
|
|
174
|
+
closedByRoom.set(binding.client_ref, frame.reason || 'closed_from_room');
|
|
175
|
+
console.error(`[sidevoice] room closed voice for ${binding.thread}`);
|
|
176
|
+
scheduleExit(); return;
|
|
177
|
+
}
|
|
126
178
|
case 'connector.error': lastError = frame.error; console.error('[sidevoice] room: ' + frame.error); return;
|
|
127
179
|
}
|
|
128
180
|
}
|
|
129
181
|
|
|
130
182
|
function snapshot() {
|
|
131
|
-
return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError,
|
|
132
|
-
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery
|
|
183
|
+
return { host: hostId, connected, protocol: PROTOCOL, outbox: outbox.length, room_error: lastError, closed_by_room: [...closedByRoom.keys()],
|
|
184
|
+
bindings: [...bindings.values()].map(({ binding_id, client_ref, harness, thread, title, delivery, capabilities }) =>
|
|
185
|
+
({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind, capabilities })) };
|
|
133
186
|
}
|
|
134
187
|
function scheduleExit() {
|
|
135
188
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -147,23 +200,28 @@ async function command(client, input) {
|
|
|
147
200
|
const params = input.params || {};
|
|
148
201
|
switch (input.method) {
|
|
149
202
|
case 'register': {
|
|
150
|
-
const { client_ref, harness, thread, title, delivery, inbound } = params;
|
|
203
|
+
const { client_ref, harness, thread, title, delivery, inbound, capabilities, engine } = params;
|
|
151
204
|
if (!client_ref || !thread || !delivery?.kind) throw new Error('client_ref, thread and delivery are required');
|
|
205
|
+
closedByRoom.delete(client_ref); // joining again is the user's explicit request
|
|
152
206
|
const existing = [...bindings.values()].find(b => b.client_ref === client_ref);
|
|
153
|
-
if (existing) {
|
|
207
|
+
if (existing) {
|
|
208
|
+
Object.assign(existing, { owner: client, delivery, inbound, capabilities });
|
|
209
|
+
client.bindings.add(existing);
|
|
210
|
+
return { binding_id: existing.binding_id, thread, connected };
|
|
211
|
+
}
|
|
154
212
|
const local_id = 'local-' + randomUUID();
|
|
155
|
-
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, owner: client };
|
|
156
|
-
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open();
|
|
213
|
+
const binding = { binding_id: local_id, client_ref, harness, thread, title, delivery, inbound, capabilities, owner: client };
|
|
214
|
+
bindings.set(local_id, binding); client.bindings.add(binding); clearTimeout(idleTimer); open(); watchWork();
|
|
157
215
|
const frame = await new Promise((resolve, reject) => {
|
|
158
216
|
const timer = setTimeout(() => { registering.delete(client_ref); reject(new Error(connected ? 'The room did not confirm the binding' : 'The room is unreachable; retrying in the background')); }, 10_000);
|
|
159
217
|
registering.set(client_ref, { resolve: f => { clearTimeout(timer); registering.delete(client_ref); resolve(f); }, reject: e => { clearTimeout(timer); registering.delete(client_ref); reject(e); } });
|
|
160
|
-
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound })) { /* sent on welcome */ }
|
|
218
|
+
if (!send({ type: 'binding.register', client_ref, harness, thread, title, inbound, capabilities, engine })) { /* sent on welcome */ }
|
|
161
219
|
}).catch(error => { if (!connected) return null; bindings.delete(binding.binding_id); client.bindings.delete(binding); throw error; });
|
|
162
220
|
return { binding_id: frame?.binding_id || binding.binding_id, thread, connected, pending: !frame };
|
|
163
221
|
}
|
|
164
222
|
case 'publish': {
|
|
165
223
|
const binding = bindings.get(params.binding_id) || [...bindings.values()].find(b => b.client_ref === params.client_ref);
|
|
166
|
-
if (!binding) throw new Error('Unknown binding');
|
|
224
|
+
if (!binding) throw new Error(closedByRoom.has(params.client_ref) ? 'CLOSED_BY_ROOM' : 'Unknown binding');
|
|
167
225
|
const speech = { type: 'speech.publish', event_id: params.event_id || randomUUID(), binding_id: binding.binding_id,
|
|
168
226
|
session_id: params.session_id, revision: params.revision, utterance_id: params.utterance_id || randomUUID(), text: params.text, language: params.language };
|
|
169
227
|
outbox.push(speech); saveOutbox();
|
|
@@ -176,6 +234,48 @@ async function command(client, input) {
|
|
|
176
234
|
const { type, event_id, ...result } = reply;
|
|
177
235
|
return result;
|
|
178
236
|
}
|
|
237
|
+
case 'read': {
|
|
238
|
+
// A harness hook says the conversation admitted this voice message: the room learns it was read.
|
|
239
|
+
const { thread, message_id, session_id, revision, turn_id } = params;
|
|
240
|
+
if (!thread || !message_id) throw new Error('thread and message_id are required');
|
|
241
|
+
const binding = [...bindings.values()].find(b => b.thread === thread || b.client_ref === thread);
|
|
242
|
+
if (!binding) return { status: 'no_binding' };
|
|
243
|
+
if (readReported.has(message_id)) return { status: 'already_reported' };
|
|
244
|
+
readReported.add(message_id); if (readReported.size > 512) readReported.delete(readReported.values().next().value);
|
|
245
|
+
const sent = send({ type: 'input.read', binding_id: binding.binding_id, message_id, session_id, revision, turn_id: turn_id || null });
|
|
246
|
+
return { status: sent ? 'sent' : 'offline' };
|
|
247
|
+
}
|
|
248
|
+
case 'turn_end': {
|
|
249
|
+
const binding = [...bindings.values()].find(b => b.thread === params.thread || b.client_ref === params.thread);
|
|
250
|
+
if (!binding) return { status: 'no_binding' };
|
|
251
|
+
const sent = announceWork(binding, false, { turn_id: params.turn_id || null });
|
|
252
|
+
return { status: sent ? 'sent' : 'offline' };
|
|
253
|
+
}
|
|
254
|
+
case 'working': {
|
|
255
|
+
const { thread, turn_id, working } = params;
|
|
256
|
+
if (!thread || typeof working !== 'boolean' || !turn_id) throw new Error('thread, turn_id and working are required');
|
|
257
|
+
const binding = [...bindings.values()].find(b => b.thread === thread || b.client_ref === thread);
|
|
258
|
+
if (!binding) return { status: 'no_binding' };
|
|
259
|
+
binding.activeTurns ||= new Map();
|
|
260
|
+
binding.completedTurns ||= new Set();
|
|
261
|
+
if (working) {
|
|
262
|
+
if (binding.completedTurns.has(turn_id)) return { status: 'stale' };
|
|
263
|
+
if (binding.activeTurns.has(turn_id)) return { status: 'already_reported' };
|
|
264
|
+
const correlation = { turn_id,
|
|
265
|
+
...(typeof params.session_id === 'string' ? { session_id: params.session_id } : {}),
|
|
266
|
+
...(Number.isInteger(params.revision) ? { revision: params.revision } : {}) };
|
|
267
|
+
binding.activeTurns.set(turn_id, correlation);
|
|
268
|
+
const sent = announceWork(binding, true, { turn_phase: 'start', ...correlation });
|
|
269
|
+
return { status: sent ? 'sent' : 'offline' };
|
|
270
|
+
}
|
|
271
|
+
if (binding.completedTurns.has(turn_id)) return { status: 'already_reported' };
|
|
272
|
+
const correlation = binding.activeTurns.get(turn_id);
|
|
273
|
+
binding.completedTurns.add(turn_id);
|
|
274
|
+
if (!correlation) return { status: 'stale' };
|
|
275
|
+
binding.activeTurns.delete(turn_id);
|
|
276
|
+
const sent = announceWork(binding, binding.activeTurns.size > 0, { turn_phase: 'end', ...correlation });
|
|
277
|
+
return { status: sent ? 'sent' : 'offline' };
|
|
278
|
+
}
|
|
179
279
|
case 'unregister': {
|
|
180
280
|
const binding = bindings.get(params.binding_id);
|
|
181
281
|
if (binding) { bindings.delete(binding.binding_id); binding.owner?.bindings.delete(binding); if (!binding.binding_id.startsWith('local-')) send({ type: 'binding.unregister', binding_id: binding.binding_id }); }
|
package/harness-claude.mjs
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* Documented at https://code.claude.com/docs/en/cross-session-messaging */
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import net from 'node:net';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
14
|
+
import { defineHarness, envelope, SUPPORTED, WORKING_POLL } from './harness-contract.mjs';
|
|
13
15
|
|
|
14
16
|
const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
15
17
|
|
|
@@ -17,6 +19,28 @@ function readJson(file) {
|
|
|
17
19
|
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** The record Claude Code keeps for a session, or null. It publishes `status` there and keeps it current. */
|
|
23
|
+
export function sessionRecord(sessionId) {
|
|
24
|
+
const registry = path.join(configDir(), 'sessions');
|
|
25
|
+
let entries = [];
|
|
26
|
+
try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
|
|
27
|
+
for (const name of entries) {
|
|
28
|
+
const record = readJson(path.join(registry, name));
|
|
29
|
+
if (record?.sessionId === sessionId) return record;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether that session is working on something right now: true, false, or null when it cannot be told.
|
|
35
|
+
* This is Claude Code's own bookkeeping, not a published interface: an unknown value answers null rather
|
|
36
|
+
* than guessing, and the room falls back to what the conversation says about its own replies. */
|
|
37
|
+
export function sessionWorking(sessionId) {
|
|
38
|
+
const status = sessionRecord(sessionId)?.status;
|
|
39
|
+
if (status === 'busy') return true;
|
|
40
|
+
if (status === 'idle' || status === 'ready' || status === 'waiting') return false;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
/** The pid of the session with this id, from Claude Code's own session registry. */
|
|
21
45
|
function sessionPid(sessionId) {
|
|
22
46
|
const registry = path.join(configDir(), 'sessions');
|
|
@@ -52,6 +76,16 @@ function settingsFromFlag(args) {
|
|
|
52
76
|
|
|
53
77
|
const BYPASS_MODES = new Set(['bypassPermissions']);
|
|
54
78
|
|
|
79
|
+
/** Which model this conversation runs, read from the session's own launch line — no model is asked to
|
|
80
|
+
* say what it is. Absent rather than guessed when the launcher did not name one (the CLI's default). */
|
|
81
|
+
export function sessionEngine(sessionId) {
|
|
82
|
+
const args = launchArgs(sessionPid(sessionId));
|
|
83
|
+
if (!args) return null;
|
|
84
|
+
const model = flag(args, '--model'), effort = flag(args, '--effort'), thinking = flag(args, '--thinking');
|
|
85
|
+
if (!model && !effort) return null;
|
|
86
|
+
return { model: model || null, effort: effort || null, thinking: thinking || null };
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
/** Will an injected message be delivered to this Claude session, or held for its user? */
|
|
56
90
|
export function inspectInbound(sessionId) {
|
|
57
91
|
const pid = sessionPid(sessionId);
|
|
@@ -86,3 +120,70 @@ export function inspectInbound(sessionId) {
|
|
|
86
120
|
confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
|
|
87
121
|
};
|
|
88
122
|
}
|
|
123
|
+
|
|
124
|
+
/** Identity and private inbox inherited by the MCP façade Claude Code spawned. */
|
|
125
|
+
export function sessionIdentity({ env = process.env } = {}) {
|
|
126
|
+
if (!env.CLAUDE_CODE_SESSION_ID) return null;
|
|
127
|
+
return {
|
|
128
|
+
harness: 'claude',
|
|
129
|
+
thread: env.CLAUDE_CODE_SESSION_ID,
|
|
130
|
+
...(env.CLAUDE_CODE_MESSAGING_SOCKET ? { delivery: {
|
|
131
|
+
kind: 'claude-uds',
|
|
132
|
+
socket: env.CLAUDE_CODE_MESSAGING_SOCKET,
|
|
133
|
+
token: env.CLAUDE_CODE_MESSAGING_TOKEN || '',
|
|
134
|
+
} } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Claude Code's session inbox sends no acknowledgement. A prompt-admitted hook supplies the
|
|
139
|
+
* separate read receipt; this method reports only what the socket itself proves. */
|
|
140
|
+
export function deliver(delivery, event) {
|
|
141
|
+
if (delivery?.kind !== 'claude-uds') throw new Error(`Unsupported Claude delivery kind: ${delivery?.kind}`);
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const started = Date.now();
|
|
144
|
+
const socket = net.createConnection(delivery.socket);
|
|
145
|
+
let settled = false, wrote = 0, replied = '';
|
|
146
|
+
const finish = (error, status, detail) => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
settled = true; clearTimeout(timer); socket.destroy();
|
|
149
|
+
if (error) return reject(error);
|
|
150
|
+
resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
|
|
151
|
+
};
|
|
152
|
+
const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
|
|
153
|
+
socket.on('error', error => finish(error));
|
|
154
|
+
socket.on('data', chunk => { replied += chunk; });
|
|
155
|
+
socket.on('connect', () => {
|
|
156
|
+
socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
|
|
157
|
+
socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
|
|
158
|
+
wrote = Date.now();
|
|
159
|
+
});
|
|
160
|
+
socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A configured Stop hook is a direct end-of-turn signal, independent of session polling. */
|
|
165
|
+
export function endOfTurn(payload, env = process.env) {
|
|
166
|
+
if (!payload || !/^stop$/i.test(String(payload.hook_event_name || payload.hookEventName || ''))) return null;
|
|
167
|
+
const identity = sessionIdentity({ env });
|
|
168
|
+
return identity ? { thread: identity.thread, turn_id: payload.turn_id || null } : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const claudeHarness = defineHarness({
|
|
172
|
+
name: 'claude',
|
|
173
|
+
workingSource: WORKING_POLL,
|
|
174
|
+
capabilities: {
|
|
175
|
+
deliver: SUPPORTED,
|
|
176
|
+
inspectInbound: SUPPORTED,
|
|
177
|
+
working: SUPPORTED,
|
|
178
|
+
endOfTurn: SUPPORTED,
|
|
179
|
+
sessionIdentity: SUPPORTED,
|
|
180
|
+
},
|
|
181
|
+
deliver,
|
|
182
|
+
inspectInbound,
|
|
183
|
+
engine: sessionEngine,
|
|
184
|
+
working: sessionWorking,
|
|
185
|
+
endOfTurn,
|
|
186
|
+
sessionIdentity,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export default claudeHarness;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Codex harness implementation.
|
|
2
|
+
*
|
|
3
|
+
* Delivery and identity are available to the stdio MCP façade. Configured UserPromptSubmit and
|
|
4
|
+
* Stop hooks report the mechanical start and end of every interactive turn. */
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { defineHarness, envelope, SUPPORTED, UNSUPPORTED, WORKING_EVENT } from './harness-contract.mjs';
|
|
7
|
+
|
|
8
|
+
function turnMetadata(meta) {
|
|
9
|
+
let turn = meta?.['x-codex-turn-metadata'] || {};
|
|
10
|
+
if (typeof turn === 'string') {
|
|
11
|
+
try { turn = JSON.parse(turn); } catch { turn = {}; }
|
|
12
|
+
}
|
|
13
|
+
return turn;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sessionIdentity({ meta, env = process.env, payload } = {}) {
|
|
17
|
+
const turn = turnMetadata(meta);
|
|
18
|
+
const thread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId
|
|
19
|
+
|| meta?.codex_thread_id || turn.thread_id || payload?.session_id || payload?.thread_id
|
|
20
|
+
|| env.CODEX_THREAD_ID;
|
|
21
|
+
if (!thread) return null;
|
|
22
|
+
const delivery = env.SIDEVOICE_DELIVERY_URL
|
|
23
|
+
? { kind: 'http', url: env.SIDEVOICE_DELIVERY_URL, thread }
|
|
24
|
+
: { kind: 'codex-queue', thread };
|
|
25
|
+
return { harness: 'codex', thread, delivery };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deliver(delivery, event) {
|
|
29
|
+
if (delivery?.kind === 'http') {
|
|
30
|
+
return fetch(delivery.url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
|
|
34
|
+
session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
|
|
35
|
+
signal: AbortSignal.timeout(30_000),
|
|
36
|
+
}).then(async response => {
|
|
37
|
+
if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
|
|
38
|
+
return { status: 'accepted', detail: `receiver answered ${response.status}` };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (delivery?.kind !== 'codex-queue') throw new Error(`Unsupported Codex delivery kind: ${delivery?.kind}`);
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
|
|
44
|
+
const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
|
|
45
|
+
execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
46
|
+
if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
|
|
47
|
+
resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Which model this thread runs, from the launch line of the process that owns it, when it says. */
|
|
53
|
+
function engine(thread, env = process.env) {
|
|
54
|
+
const named = env.CODEX_MODEL || null;
|
|
55
|
+
return named ? { model: named, effort: env.CODEX_REASONING_EFFORT || null, thinking: null } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function endOfTurn(payload, env = process.env) {
|
|
59
|
+
if (!payload || !/^stop$/i.test(String(payload.hook_event_name || payload.hookEventName || ''))) return null;
|
|
60
|
+
const identity = sessionIdentity({ payload, env });
|
|
61
|
+
return identity ? { thread: identity.thread, turn_id: payload.turn_id || null } : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A Codex lifecycle hook, normalized as a per-thread working transition. */
|
|
65
|
+
function working(payload, env = process.env) {
|
|
66
|
+
const event = String(payload?.hook_event_name || payload?.hookEventName || '');
|
|
67
|
+
if (!/^(userpromptsubmit|stop)$/i.test(event)) return null;
|
|
68
|
+
const identity = sessionIdentity({ payload, env });
|
|
69
|
+
if (!identity || typeof identity.thread !== 'string' || !identity.thread
|
|
70
|
+
|| typeof payload.turn_id !== 'string' || !payload.turn_id) return null;
|
|
71
|
+
return {
|
|
72
|
+
thread: identity.thread,
|
|
73
|
+
turn_id: payload.turn_id || null,
|
|
74
|
+
working: /^userpromptsubmit$/i.test(event),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const codexHarness = defineHarness({
|
|
79
|
+
name: 'codex',
|
|
80
|
+
workingSource: WORKING_EVENT,
|
|
81
|
+
capabilities: {
|
|
82
|
+
deliver: SUPPORTED,
|
|
83
|
+
inspectInbound: UNSUPPORTED,
|
|
84
|
+
working: SUPPORTED,
|
|
85
|
+
endOfTurn: SUPPORTED,
|
|
86
|
+
sessionIdentity: SUPPORTED,
|
|
87
|
+
},
|
|
88
|
+
deliver,
|
|
89
|
+
engine,
|
|
90
|
+
working,
|
|
91
|
+
endOfTurn,
|
|
92
|
+
sessionIdentity,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export default codexHarness;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The harness boundary. A known harness declares every capability; callers never infer support
|
|
2
|
+
* from a missing method. Missing or malformed declarations remain unknown, never false. */
|
|
3
|
+
|
|
4
|
+
export const CAPABILITIES = Object.freeze([
|
|
5
|
+
'deliver',
|
|
6
|
+
'inspectInbound',
|
|
7
|
+
'working',
|
|
8
|
+
'endOfTurn',
|
|
9
|
+
'sessionIdentity',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const SUPPORTED = 'supported';
|
|
13
|
+
export const UNSUPPORTED = 'unsupported';
|
|
14
|
+
export const UNKNOWN = 'unknown';
|
|
15
|
+
export const WORKING_POLL = 'poll';
|
|
16
|
+
export const WORKING_EVENT = 'event';
|
|
17
|
+
const DECLARED_STATES = new Set([SUPPORTED, UNSUPPORTED]);
|
|
18
|
+
const WORKING_SOURCES = new Set([WORKING_POLL, WORKING_EVENT]);
|
|
19
|
+
|
|
20
|
+
export function capabilityState(harness, capability) {
|
|
21
|
+
const state = harness?.capabilities?.[capability];
|
|
22
|
+
return DECLARED_STATES.has(state) ? state : UNKNOWN;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function advertisedCapabilities(harness) {
|
|
26
|
+
return Object.fromEntries(CAPABILITIES.map(capability => [capability, capabilityState(harness, capability)]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defineHarness(definition) {
|
|
30
|
+
if (!definition?.name) throw new Error('A harness needs a name');
|
|
31
|
+
for (const capability of CAPABILITIES) {
|
|
32
|
+
const state = definition.capabilities?.[capability];
|
|
33
|
+
if (!DECLARED_STATES.has(state)) throw new Error(`${definition.name} must declare ${capability}`);
|
|
34
|
+
if (state === SUPPORTED && typeof definition[capability] !== 'function') {
|
|
35
|
+
throw new Error(`${definition.name} declares ${capability} supported but does not implement it`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (definition.capabilities.working === SUPPORTED && !WORKING_SOURCES.has(definition.workingSource)) {
|
|
39
|
+
throw new Error(`${definition.name} declares working supported but does not declare a working source`);
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({ ...definition, capabilities: Object.freeze({ ...definition.capabilities }) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The header the voice skill expects before the user's literal words. */
|
|
45
|
+
export function envelope(event) {
|
|
46
|
+
const header = {
|
|
47
|
+
channel: event.channel === 'room-control' ? 'room-control' : 'voice',
|
|
48
|
+
session_id: event.session_id,
|
|
49
|
+
revision: event.revision,
|
|
50
|
+
message_id: event.message_id,
|
|
51
|
+
};
|
|
52
|
+
return JSON.stringify(header) + '\n\n' + event.text;
|
|
53
|
+
}
|
package/harness-http.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Generic HTTP harness used by explicitly configured external receivers. */
|
|
2
|
+
import { defineHarness, SUPPORTED, UNSUPPORTED } from './harness-contract.mjs';
|
|
3
|
+
|
|
4
|
+
async function deliver(delivery, event) {
|
|
5
|
+
if (delivery?.kind !== 'http') throw new Error(`Unsupported HTTP delivery kind: ${delivery?.kind}`);
|
|
6
|
+
const response = await fetch(delivery.url, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
headers: { 'content-type': 'application/json' },
|
|
9
|
+
body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
|
|
10
|
+
session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
|
|
11
|
+
signal: AbortSignal.timeout(30_000),
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
|
|
14
|
+
return { status: 'accepted', detail: `receiver answered ${response.status}` };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sessionIdentity({ env = process.env } = {}) {
|
|
18
|
+
if (!env.SIDEVOICE_THREAD || !env.SIDEVOICE_DELIVERY_URL) return null;
|
|
19
|
+
return {
|
|
20
|
+
harness: env.SIDEVOICE_HARNESS || 'http',
|
|
21
|
+
thread: env.SIDEVOICE_THREAD,
|
|
22
|
+
delivery: { kind: 'http', url: env.SIDEVOICE_DELIVERY_URL, thread: env.SIDEVOICE_THREAD },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const httpHarness = defineHarness({
|
|
27
|
+
name: 'http',
|
|
28
|
+
capabilities: {
|
|
29
|
+
deliver: SUPPORTED,
|
|
30
|
+
inspectInbound: UNSUPPORTED,
|
|
31
|
+
working: UNSUPPORTED,
|
|
32
|
+
endOfTurn: UNSUPPORTED,
|
|
33
|
+
sessionIdentity: SUPPORTED,
|
|
34
|
+
},
|
|
35
|
+
deliver,
|
|
36
|
+
sessionIdentity,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default httpHarness;
|
package/harnesses.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Registry and selection for the harness modules. The façade and connector ask this registry;
|
|
2
|
+
* neither contains harness-name branches. */
|
|
3
|
+
import { claudeHarness } from './harness-claude.mjs';
|
|
4
|
+
import { codexHarness } from './harness-codex.mjs';
|
|
5
|
+
import { httpHarness } from './harness-http.mjs';
|
|
6
|
+
|
|
7
|
+
export const harnesses = Object.freeze({ claude: claudeHarness, codex: codexHarness, http: httpHarness });
|
|
8
|
+
|
|
9
|
+
export function harnessFor(name) {
|
|
10
|
+
return harnesses[name] || httpHarness;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function identifyHarness(meta, env = process.env) {
|
|
14
|
+
for (const harness of [claudeHarness, codexHarness, httpHarness]) {
|
|
15
|
+
const identity = harness.sessionIdentity({ meta, env });
|
|
16
|
+
if (identity?.delivery) return { ...identity, module: harness };
|
|
17
|
+
}
|
|
18
|
+
throw new Error('Cannot tell which conversation this is: not launched by Claude Code or Codex, and no SIDEVOICE_THREAD/SIDEVOICE_DELIVERY_URL set');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Which harness a hook invocation belongs to.
|
|
22
|
+
*
|
|
23
|
+
* The installed hook command names it (`sidevoice hook --harness <name>`, or SIDEVOICE_HOOK_HARNESS):
|
|
24
|
+
* a harness started from another harness's terminal inherits that one's environment variables, and both
|
|
25
|
+
* hook payloads carry a `session_id`, so neither the environment nor the payload can tell them apart on
|
|
26
|
+
* its own. Naming it at the point of install is mechanical; guessing is not. Without a declaration the
|
|
27
|
+
* modules are asked in order, which only holds when a single harness runs on the machine. */
|
|
28
|
+
export function identifyHookHarness(payload, env = process.env) {
|
|
29
|
+
const declared = env.SIDEVOICE_HOOK_HARNESS;
|
|
30
|
+
const candidates = declared ? [harnesses[declared]].filter(Boolean) : [claudeHarness, codexHarness, httpHarness];
|
|
31
|
+
for (const harness of candidates) {
|
|
32
|
+
const identity = harness.sessionIdentity({ payload, env });
|
|
33
|
+
if (identity) return { ...identity, module: harness };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
package/hook.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Harness hook: `sidevoice hook` on stdin reports lifecycle events for event-backed harnesses. When an admitted
|
|
3
|
+
* prompt is a Sidevoice voice message it also reports the read receipt and hands the harness context so the model
|
|
4
|
+
* speaks first. It never blocks the harness: any failure exits 0. */
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { capabilityState, SUPPORTED, WORKING_EVENT } from './harness-contract.mjs';
|
|
9
|
+
import { harnesses, identifyHookHarness } from './harnesses.mjs';
|
|
10
|
+
|
|
11
|
+
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
12
|
+
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
13
|
+
|
|
14
|
+
/** The envelope a harness delivery module puts before the user's words. Anything else is not ours. */
|
|
15
|
+
export function voiceEnvelope(prompt) {
|
|
16
|
+
if (typeof prompt !== 'string' || !prompt.startsWith('{')) return null;
|
|
17
|
+
const end = prompt.indexOf('}');
|
|
18
|
+
if (end < 0) return null;
|
|
19
|
+
let header;
|
|
20
|
+
try { header = JSON.parse(prompt.slice(0, end + 1)); } catch { return null; }
|
|
21
|
+
if (!header || header.channel !== 'voice' || !header.message_id || !header.session_id || !Number.isInteger(header.revision)) return null;
|
|
22
|
+
return { message_id: header.message_id, session_id: header.session_id, revision: header.revision, text: prompt.slice(end + 1).trim() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What this hook invocation means, or null when it is not a Sidevoice voice message being admitted. */
|
|
26
|
+
export function interpret(payload, env = process.env) {
|
|
27
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
28
|
+
const event = payload.hook_event_name || payload.hookEventName;
|
|
29
|
+
if (event && !/^userpromptsubmit$/i.test(String(event))) return null;
|
|
30
|
+
const envelope = voiceEnvelope(payload.prompt);
|
|
31
|
+
if (!envelope) return null;
|
|
32
|
+
const identity = identifyHookHarness(payload, env);
|
|
33
|
+
return identity ? { thread: identity.thread, turn_id: payload.turn_id || null, ...envelope } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A harness-native end-of-turn signal, normalized by that harness's module. */
|
|
37
|
+
export function interpretTurnEnd(payload, env = process.env) {
|
|
38
|
+
const identity = identifyHookHarness(payload, env);
|
|
39
|
+
if (!identity || identity.module.workingSource === WORKING_EVENT
|
|
40
|
+
|| capabilityState(identity.module, 'endOfTurn') !== SUPPORTED) return null;
|
|
41
|
+
return identity.module.endOfTurn(payload, env);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A lifecycle-backed harness working transition, plus Sidevoice correlation when this is our prompt. */
|
|
45
|
+
export function interpretWorking(payload, env = process.env) {
|
|
46
|
+
const identity = identifyHookHarness(payload, env);
|
|
47
|
+
if (!identity || capabilityState(identity.module, 'working') !== SUPPORTED
|
|
48
|
+
|| identity.module.workingSource !== WORKING_EVENT) return null;
|
|
49
|
+
const transition = identity.module.working(payload, env);
|
|
50
|
+
if (!transition) return null;
|
|
51
|
+
const reading = transition.working ? voiceEnvelope(payload.prompt) : null;
|
|
52
|
+
return reading
|
|
53
|
+
? { ...transition, session_id: reading.session_id, revision: reading.revision, message_id: reading.message_id }
|
|
54
|
+
: transition;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The harness this hook was installed for, as the hook command itself names it. Nothing is inferred
|
|
58
|
+
* from the environment: one harness's variables survive into another harness launched from its terminal. */
|
|
59
|
+
export function declaredHarness(argv = process.argv, env = process.env) {
|
|
60
|
+
const flag = argv.indexOf('--harness');
|
|
61
|
+
const named = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith('--harness='))?.slice('--harness='.length);
|
|
62
|
+
return named || env.SIDEVOICE_HOOK_HARNESS || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The context handed back to the harness: the acknowledgement is asked for at the moment the message is read. */
|
|
66
|
+
export function nudge(reading) {
|
|
67
|
+
return `A voice message just arrived from the room (session ${reading.session_id}, revision ${reading.revision}). `
|
|
68
|
+
+ 'Before any other tool, publish a short spoken acknowledgement with voice_say that says what you understood and what you will do next, '
|
|
69
|
+
+ 'using that session_id and revision; then continue the work and publish the result by voice as well.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function report(method, params, timeoutMs = 1500) {
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
const socket = net.createConnection(socketPath);
|
|
75
|
+
const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
|
|
76
|
+
const timer = setTimeout(() => done({ ok: false, error: 'timeout' }), timeoutMs);
|
|
77
|
+
let buffer = '';
|
|
78
|
+
socket.on('error', error => done({ ok: false, error: error.code || error.message }));
|
|
79
|
+
socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method, params }) + '\n'));
|
|
80
|
+
socket.on('data', chunk => {
|
|
81
|
+
buffer += chunk; const index = buffer.indexOf('\n');
|
|
82
|
+
if (index < 0) return;
|
|
83
|
+
try { done(JSON.parse(buffer.slice(0, index))); } catch { done({ ok: false, error: 'bad reply' }); }
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
let raw = '';
|
|
90
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
91
|
+
let payload = null;
|
|
92
|
+
try { payload = JSON.parse(raw); } catch {}
|
|
93
|
+
const named = declaredHarness();
|
|
94
|
+
if (named && !harnesses[named]) return console.error('[sidevoice hook] unknown harness: ' + named);
|
|
95
|
+
const env = named ? { ...process.env, SIDEVOICE_HOOK_HARNESS: named } : process.env;
|
|
96
|
+
const transition = interpretWorking(payload, env);
|
|
97
|
+
if (transition) {
|
|
98
|
+
const outcome = await report('working', transition);
|
|
99
|
+
if (!outcome.ok) console.error('[sidevoice hook] working report not sent: ' + (outcome.error || 'unknown'));
|
|
100
|
+
if (!transition.working) return;
|
|
101
|
+
}
|
|
102
|
+
const ended = interpretTurnEnd(payload, env);
|
|
103
|
+
if (ended) {
|
|
104
|
+
const outcome = await report('turn_end', ended);
|
|
105
|
+
if (!outcome.ok) console.error('[sidevoice hook] end-of-turn report not sent: ' + (outcome.error || 'unknown'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const reading = interpret(payload, env);
|
|
109
|
+
if (!reading) return;
|
|
110
|
+
const outcome = await report('read', reading);
|
|
111
|
+
if (!outcome.ok) console.error('[sidevoice hook] read receipt not sent: ' + (outcome.error || 'unknown'));
|
|
112
|
+
if (process.env.SIDEVOICE_HOOK_NUDGE !== '0') {
|
|
113
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: nudge(reading) } }) + '\n');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] && /hook\.mjs$/.test(process.argv[1]) || process.env.SIDEVOICE_HOOK_MAIN === '1') {
|
|
118
|
+
main().catch(error => { console.error('[sidevoice hook] ' + (error?.message || error)); }).finally(() => process.exit(0));
|
|
119
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import {
|
|
10
|
+
import { advertisedCapabilities, capabilityState, SUPPORTED } from './harness-contract.mjs';
|
|
11
|
+
import { harnessFor, identifyHarness } from './harnesses.mjs';
|
|
11
12
|
|
|
12
13
|
const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
13
14
|
const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
@@ -20,29 +21,11 @@ const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice r
|
|
|
20
21
|
- A progress publication is not itself a listening point. Divide substantive execution into bounded steps and, after each tool result or operational boundary, process newly arrived user input before starting the next step. Do not add artificial sleeps or fixed pauses.
|
|
21
22
|
- If a new user message arrives during active work, treat it as an addition, refinement, or replacement according to its meaning. Stop not-yet-started obsolete work, preserve completed work that remains useful, acknowledge the new interpretation before continuing, and do not later answer a stale request. A tool already running may finish before the correction takes effect; delegation is not a substitute for listening.
|
|
22
23
|
- A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
|
|
23
|
-
-
|
|
24
|
+
- If the user closes this conversation's voice channel from the room, the connection is removed: voice_say then fails saying so. Continue in writing and do not try to speak again; call voice_connect only when the user asks for voice again.
|
|
24
25
|
- voice_status reports whether the room can currently reach this conversation.
|
|
26
|
+
- On Claude Code, if a skill named voice-room is available, joining through it (/voice-room) is preferred: it registers, for this session only, the hook that gives the room read receipts and asks you to speak first.
|
|
25
27
|
- If voice_connect returns inbound.ok false, voice will look sent and never arrive: this harness holds or refuses messages posted by other local processes. Tell the user what inbound.reason says, offer inbound.remedy in your own words including what safeguard the machine-wide option removes, and let them choose. Do not change their settings without being asked to.`;
|
|
26
28
|
|
|
27
|
-
/** Who this façade speaks for, decided by what spawned it — never by the model. */
|
|
28
|
-
function identity(meta) {
|
|
29
|
-
if (process.env.CLAUDE_CODE_SESSION_ID && process.env.CLAUDE_CODE_MESSAGING_SOCKET) {
|
|
30
|
-
return { harness: 'claude', thread: process.env.CLAUDE_CODE_SESSION_ID,
|
|
31
|
-
delivery: { kind: 'claude-uds', socket: process.env.CLAUDE_CODE_MESSAGING_SOCKET, token: process.env.CLAUDE_CODE_MESSAGING_TOKEN || '' } };
|
|
32
|
-
}
|
|
33
|
-
let turn = meta?.['x-codex-turn-metadata'] || {};
|
|
34
|
-
if (typeof turn === 'string') { try { turn = JSON.parse(turn); } catch { turn = {}; } }
|
|
35
|
-
const codexThread = meta?.['openai/threadId'] || meta?.['openai/thread_id'] || meta?.codexThreadId || meta?.codex_thread_id || turn.thread_id || process.env.CODEX_THREAD_ID;
|
|
36
|
-
if (codexThread) {
|
|
37
|
-
const delivery = process.env.SIDEVOICE_DELIVERY_URL ? { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: codexThread } : { kind: 'codex-queue', thread: codexThread };
|
|
38
|
-
return { harness: 'codex', thread: codexThread, delivery };
|
|
39
|
-
}
|
|
40
|
-
if (process.env.SIDEVOICE_THREAD && process.env.SIDEVOICE_DELIVERY_URL) {
|
|
41
|
-
return { harness: process.env.SIDEVOICE_HARNESS || 'http', thread: process.env.SIDEVOICE_THREAD, delivery: { kind: 'http', url: process.env.SIDEVOICE_DELIVERY_URL, thread: process.env.SIDEVOICE_THREAD } };
|
|
42
|
-
}
|
|
43
|
-
throw new Error('Cannot tell which conversation this is: not launched by Claude Code or Codex, and no SIDEVOICE_THREAD/SIDEVOICE_DELIVERY_URL set');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
29
|
// ----- one persistent connection to the connector -----
|
|
47
30
|
let ipc = null, ipcBuffer = '', ipcSerial = 0;
|
|
48
31
|
const ipcWaiting = new Map();
|
|
@@ -94,32 +77,55 @@ const tools = [
|
|
|
94
77
|
{ name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
95
78
|
];
|
|
96
79
|
let binding = null;
|
|
80
|
+
function inboundFor(harness, thread) {
|
|
81
|
+
return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
|
|
82
|
+
}
|
|
97
83
|
async function invoke(name, args, meta) {
|
|
98
84
|
if (name === 'voice_status') {
|
|
99
|
-
const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [] };
|
|
100
|
-
|
|
85
|
+
const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [], closed_by_room: [] };
|
|
86
|
+
// The room may have closed this conversation's voice since we joined: the connector is the truth.
|
|
87
|
+
const closed = !!binding && (status.closed_by_room || []).includes(binding.client_ref);
|
|
88
|
+
if (closed) binding = null;
|
|
89
|
+
const module = binding ? harnessFor(binding.harness) : null;
|
|
90
|
+
const inbound = binding ? inboundFor(module, binding.client_ref) : null;
|
|
101
91
|
return { joined: !!binding, room_reachable: status.connected, room_error: status.room_error || null,
|
|
102
|
-
binding_id: binding?.binding_id || null, harness: binding?.harness || null,
|
|
92
|
+
binding_id: binding?.binding_id || null, harness: binding?.harness || null,
|
|
93
|
+
capabilities: binding?.capabilities || null, inbound,
|
|
94
|
+
...(closed ? { closed_by_room: true, note: 'The user closed this conversation\'s voice channel from the room. Continue in writing; call voice_connect again only if they ask for voice.' } : {}) };
|
|
103
95
|
}
|
|
104
96
|
if (name === 'voice_connect') {
|
|
105
|
-
const who =
|
|
97
|
+
const who = identifyHarness(meta);
|
|
106
98
|
const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
|
|
107
99
|
// Refuse rather than join a room we cannot hear from: a conversation whose harness holds
|
|
108
100
|
// what the room posts would sit in the list looking present while the user talks to nobody.
|
|
109
|
-
const inbound = who.
|
|
110
|
-
if (inbound
|
|
101
|
+
const inbound = inboundFor(who.module, who.thread);
|
|
102
|
+
if (inbound?.ok === false) {
|
|
111
103
|
const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
|
|
112
104
|
error.data = { inbound };
|
|
113
105
|
throw error;
|
|
114
106
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
107
|
+
const capabilities = advertisedCapabilities(who.module);
|
|
108
|
+
// Which model is answering, read from the session's own launch line rather than asked of the model.
|
|
109
|
+
let engine = null;
|
|
110
|
+
try { engine = who.module.engine?.(who.thread) || null; } catch { engine = null; }
|
|
111
|
+
const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
|
|
112
|
+
title, delivery: who.delivery, inbound, capabilities, engine });
|
|
113
|
+
binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
|
|
117
114
|
return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
|
|
118
|
-
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, inbound };
|
|
115
|
+
binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound };
|
|
119
116
|
}
|
|
120
117
|
if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
|
|
121
118
|
if (name === 'voice_say') {
|
|
122
|
-
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = await rpc('publish', { binding_id: binding.binding_id, client_ref: binding.client_ref, text: args.text, session_id: args.session_id, revision: args.revision, utterance_id: args.utterance_id, language: args.language });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error.message === 'CLOSED_BY_ROOM') {
|
|
124
|
+
binding = null;
|
|
125
|
+
throw new Error('The user closed this conversation\'s voice channel from the room. Continue in writing and do not publish speech; call voice_connect again only if the user asks for voice.');
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
123
129
|
return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
|
|
124
130
|
}
|
|
125
131
|
if (name === 'voice_disconnect') { const result = await rpc('unregister', { binding_id: binding.binding_id }); binding = null; return { status: 'left', room_reachable: result.connected }; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sidevoice/uplink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Sidevoice client side: the stdio MCP server your agent uses, one outbound uplink per machine to the room, one-time pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,9 +14,15 @@
|
|
|
14
14
|
"cli.mjs",
|
|
15
15
|
"mcp.mjs",
|
|
16
16
|
"connector.mjs",
|
|
17
|
-
"
|
|
17
|
+
"harness-contract.mjs",
|
|
18
|
+
"harnesses.mjs",
|
|
18
19
|
"harness-claude.mjs",
|
|
20
|
+
"harness-codex.mjs",
|
|
21
|
+
"harness-http.mjs",
|
|
19
22
|
"pair.mjs",
|
|
23
|
+
"hook.mjs",
|
|
24
|
+
"skill.mjs",
|
|
25
|
+
"skill/",
|
|
20
26
|
"README.md"
|
|
21
27
|
],
|
|
22
28
|
"scripts": {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: voice-room
|
|
3
|
+
description: Join the user's Sidevoice voice room with this conversation. Use when the user asks to enable voice, join the room or talk by voice; never as a side effect of other work.
|
|
4
|
+
argument-hint: "[title for this conversation in the room]"
|
|
5
|
+
# Same shape as settings.json hooks (a list of groups): Claude Code 2.1.x registers nothing for a map here.
|
|
6
|
+
# The command names its harness: a Codex session started from this terminal inherits CLAUDE_CODE_SESSION_ID,
|
|
7
|
+
# so the hook cannot tell the two apart from the environment alone.
|
|
8
|
+
# The installer replaces __SIDEVOICE_SKILL_DIR__ with the directory it copies this file into: the hook must
|
|
9
|
+
# resolve without any variable, since ${CLAUDE_SKILL_DIR} is not expanded in a user skill's hook command.
|
|
10
|
+
hooks:
|
|
11
|
+
UserPromptSubmit:
|
|
12
|
+
- hooks:
|
|
13
|
+
- type: command
|
|
14
|
+
command: node "__SIDEVOICE_SKILL_DIR__/hook.mjs" --harness claude
|
|
15
|
+
timeout: 5
|
|
16
|
+
Stop:
|
|
17
|
+
- hooks:
|
|
18
|
+
- type: command
|
|
19
|
+
command: node "__SIDEVOICE_SKILL_DIR__/hook.mjs" --harness claude
|
|
20
|
+
timeout: 5
|
|
21
|
+
metadata:
|
|
22
|
+
sidevoice: installed copy; the source is skill/voice-room in @sidevoice/uplink, reinstall with `sidevoice skill install`
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Join the voice room for this conversation and keep it reachable.
|
|
26
|
+
|
|
27
|
+
1. Call `voice_status`. If it reports `joined` and `room_reachable`, say so in one line and stop.
|
|
28
|
+
2. Call `voice_connect` with the title `$ARGUMENTS` when given, otherwise a short label of what this conversation is about.
|
|
29
|
+
3. Tell the user in one line whether the room can reach this conversation. If `inbound.ok` is false, relay `inbound.reason` and offer `inbound.remedy` in your own words, including what safeguard it removes; change nothing yourself.
|
|
30
|
+
|
|
31
|
+
From now on this conversation has read receipts and mechanical working-state reporting: invoking this skill registered hooks that tell the room when a voice message is admitted and when the turn stops. Voice messages arrive as user messages that begin with a JSON header (`channel`, `session_id`, `revision`, `message_id`) followed by the user's words. For each one, publish a short `voice_say` with that `session_id` and `revision` before any other tool, then do the work and publish the result by voice as well. A repeated `message_id` is a redelivery: do not act on it twice.
|
package/skill.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room and,
|
|
2
|
+
* for that session only, registers the lifecycle hook. It installs the skill plus the hook's harness runtime;
|
|
3
|
+
* running install again repairs it. A directory of the same name that is not ours is never touched. */
|
|
4
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export const SKILL_NAME = 'voice-room';
|
|
11
|
+
const MARKER = 'sidevoice: installed copy';
|
|
12
|
+
const HOOK_FILES = ['hook.mjs', 'harness-contract.mjs', 'harnesses.mjs', 'harness-claude.mjs', 'harness-codex.mjs', 'harness-http.mjs'];
|
|
13
|
+
|
|
14
|
+
export function skillsDir(argv = process.argv.slice(2), env = process.env) {
|
|
15
|
+
const index = argv.indexOf('--dir');
|
|
16
|
+
if (index >= 0 && argv[index + 1]) return path.resolve(argv[index + 1]);
|
|
17
|
+
return path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'skills');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function status(dir) {
|
|
21
|
+
const target = path.join(dir, SKILL_NAME);
|
|
22
|
+
const manifest = path.join(target, 'SKILL.md');
|
|
23
|
+
if (!existsSync(target)) return { state: 'absent', target };
|
|
24
|
+
let ours = false;
|
|
25
|
+
try { ours = readFileSync(manifest, 'utf8').includes(MARKER); } catch {}
|
|
26
|
+
return { state: ours ? 'installed' : 'foreign', target, hook: existsSync(path.join(target, 'hook.mjs')) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function install(dir) {
|
|
30
|
+
const current = status(dir);
|
|
31
|
+
if (current.state === 'foreign') throw new Error(`${current.target} already holds a skill that is not Sidevoice's; remove or rename it first.`);
|
|
32
|
+
mkdirSync(current.target, { recursive: true });
|
|
33
|
+
const manifest = readFileSync(path.join(here, 'skill', SKILL_NAME, 'SKILL.md'), 'utf8').replaceAll('__SIDEVOICE_SKILL_DIR__', current.target);
|
|
34
|
+
writeFileSync(path.join(current.target, 'SKILL.md'), manifest);
|
|
35
|
+
for (const file of HOOK_FILES) cpSync(path.join(here, file), path.join(current.target, file));
|
|
36
|
+
return { ...status(dir), action: current.state === 'installed' ? 'updated' : 'installed' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function remove(dir) {
|
|
40
|
+
const current = status(dir);
|
|
41
|
+
if (current.state === 'foreign') throw new Error(`${current.target} is not Sidevoice's skill; left as it is.`);
|
|
42
|
+
if (current.state === 'installed') rmSync(current.target, { recursive: true, force: true });
|
|
43
|
+
return { state: 'absent', target: current.target, action: current.state === 'installed' ? 'removed' : 'nothing to remove' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
|
|
47
|
+
const [command] = process.argv.slice(2);
|
|
48
|
+
const dir = skillsDir();
|
|
49
|
+
try {
|
|
50
|
+
const result = command === 'install' ? install(dir) : command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
|
|
51
|
+
if (!result) { console.error('usage: sidevoice skill <install|remove|status> [--dir <skills dir>]'); process.exit(2); }
|
|
52
|
+
console.log(`${result.action || result.state}: ${result.target}`);
|
|
53
|
+
if (result.action === 'installed' || result.action === 'updated') {
|
|
54
|
+
console.log('In Claude Code, /voice-room joins the room for that conversation and gives it read receipts. New sessions see the skill; a session already open needs a restart.');
|
|
55
|
+
}
|
|
56
|
+
} catch (error) { console.error(error.message); process.exit(1); }
|
|
57
|
+
}
|
package/adapters.mjs
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/** The last mile, one function per harness. Each takes a binding's delivery target and a room event. */
|
|
2
|
-
import net from 'node:net';
|
|
3
|
-
import { execFile } from 'node:child_process';
|
|
4
|
-
|
|
5
|
-
/** The header the skill expects before the user's literal words. */
|
|
6
|
-
export function envelope(event) {
|
|
7
|
-
const header = { channel: event.channel === 'room-control' ? 'room-control' : 'voice',
|
|
8
|
-
session_id: event.session_id, revision: event.revision, message_id: event.message_id };
|
|
9
|
-
return JSON.stringify(header) + '\n\n' + event.text;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Claude Code: the session's own inbox socket, inherited by the façade that registered the binding.
|
|
13
|
-
* The inbox sends no acknowledgement, so this can never report more than what the wire showed:
|
|
14
|
-
* the peer hanging up right after the frames is the one observable sign of a refusal. */
|
|
15
|
-
function deliverClaude(delivery, event) {
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
const started = Date.now();
|
|
18
|
-
const socket = net.createConnection(delivery.socket);
|
|
19
|
-
let settled = false, wrote = 0, replied = '';
|
|
20
|
-
const finish = (error, status, detail) => {
|
|
21
|
-
if (settled) return;
|
|
22
|
-
settled = true; clearTimeout(timer); socket.destroy();
|
|
23
|
-
if (error) return reject(error);
|
|
24
|
-
resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
|
|
25
|
-
};
|
|
26
|
-
const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
|
|
27
|
-
socket.on('error', error => finish(error));
|
|
28
|
-
socket.on('data', chunk => { replied += chunk; });
|
|
29
|
-
socket.on('connect', () => {
|
|
30
|
-
socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
|
|
31
|
-
socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
|
|
32
|
-
wrote = Date.now();
|
|
33
|
-
});
|
|
34
|
-
// A hang-up right after the frames is how a refused auth or a closed inbox looks from here.
|
|
35
|
-
socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Codex: `codex queue` enqueues the next user turn on the local app-server daemon. */
|
|
40
|
-
function deliverCodex(delivery, event) {
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
const binary = process.env.SIDEVOICE_CODEX_BIN || 'codex';
|
|
43
|
-
const args = ['queue', '--thread', delivery.thread, '--message', envelope(event)];
|
|
44
|
-
execFile(binary, args, { timeout: 30_000, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
45
|
-
if (error) return reject(new Error((stderr || stdout || error.message).toString().trim().slice(0, 400)));
|
|
46
|
-
resolve({ status: 'accepted', detail: 'codex queue confirmed the thread' });
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Fallback: an HTTP receiver next to the harness (the slimmed Codex Desktop bridge speaks this). */
|
|
52
|
-
async function deliverHttp(delivery, event) {
|
|
53
|
-
const response = await fetch(delivery.url, {
|
|
54
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
55
|
-
body: JSON.stringify({ thread_id: delivery.thread, text: event.text, message_id: event.message_id,
|
|
56
|
-
session_id: event.session_id, revision: event.revision, channel: event.channel || 'voice' }),
|
|
57
|
-
signal: AbortSignal.timeout(30_000),
|
|
58
|
-
});
|
|
59
|
-
if (!response.ok) throw new Error(`Harness delivery failed (${response.status})`);
|
|
60
|
-
return { status: 'accepted', detail: `receiver answered ${response.status}` };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export const adapters = { 'claude-uds': deliverClaude, 'codex-queue': deliverCodex, http: deliverHttp };
|
|
64
|
-
|
|
65
|
-
export function deliver(delivery, event) {
|
|
66
|
-
const adapter = adapters[delivery?.kind];
|
|
67
|
-
if (!adapter) throw new Error(`Unknown delivery kind: ${delivery?.kind}`);
|
|
68
|
-
return adapter(delivery, event);
|
|
69
|
-
}
|