@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 CHANGED
@@ -1,9 +1,12 @@
1
1
  # Sidevoice uplink — the client side (`@sidevoice/uplink`)
2
2
 
3
- The client side. Three entry points behind one bin (`sidevoice`):
3
+ The client side. One bin (`sidevoice`), these entry points:
4
4
 
5
+ - `install` — registers the MCP server with the harness (re-pinned to this
6
+ version when an older one was registered) and installs the skill. Pairs with
7
+ nothing; reports whether the machine is paired and with which room.
5
8
  - `mcp` — the stdio MCP server a harness starts. One per conversation. Exposes
6
- `voice_connect`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
9
+ `voice_connect`, `voice_pair`, `voice_say`, `voice_disconnect`, `voice_status`; carries the
7
10
  operational instructions in its `initialize` result. It never talks to the
8
11
  room: it keeps one local connection to the connector for as long as the
9
12
  session lives, and the binding it registered dies with that connection.
@@ -13,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
- Adapters (`adapters.mjs`): `claude-uds` writes the voice envelope as a user
20
- message to the Claude Code session's inbox socket; `codex-queue` runs
21
- `codex queue --thread <id>`; `http` posts to any local receiver that accepts the
22
- room's message shape (for harnesses that have neither).
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` -> `binding.registered|rejected`;
26
- `binding.unregister`; `input.deliver` -> `input.ack`; `speech.publish` ->
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, three entry points. */
3
- const [, , command, ...rest] = process.argv;
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 { deliver } from './adapters.mjs';
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: run pair.mjs first (looked in ${credentialsPath})`);
28
+ if (!url || !connector_id || !token) throw new Error(`Not paired with any room (looked in ${credentialsPath}): the conversation's voice_pair, or sidevoice pair <room-url> <code>, with the code the room shows`);
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
- return { url, connector_id, token };
32
+ const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
33
+ return { url, connector_id, token, room: room.origin };
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 outcome = await deliver(binding.delivery, frame);
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 }) => ({ binding_id, client_ref, harness, thread, title, delivery: delivery.kind })) };
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) { existing.owner = client; existing.delivery = delivery; client.bindings.add(existing); return { binding_id: existing.binding_id, thread, connected }; }
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
  }
@@ -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;