@sidevoice/uplink 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,227 +0,0 @@
1
- /** What Claude Code will do with a message we post to a session's inbox, decided before we post it.
2
- *
3
- * A session that bypasses permission prompts holds an injected message for its user's approval
4
- * instead of delivering it, and the inbox sends us no receipt to say so — the write looks
5
- * identical either way. So the only honest moment to find out is at voice_connect, from the
6
- * session's own launch flags and settings. Best effort by design: a managed policy layer we
7
- * cannot read could tighten this further, and the result says so rather than pretending.
8
- * Documented at https://code.claude.com/docs/en/cross-session-messaging */
9
- import { execFileSync } from 'node:child_process';
10
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
- import net from 'node:net';
12
- import os from 'node:os';
13
- import path from 'node:path';
14
- import { defineHarness, envelope, SUPPORTED, tailJsonl } from './harness-contract.mjs';
15
-
16
- const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
17
-
18
- function readJson(file) {
19
- try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
20
- }
21
-
22
- /** The record Claude Code keeps for a session, or null. It publishes `status` there and keeps it current. */
23
- export function sessionRecord(sessionId) {
24
- const registry = path.join(configDir(), 'sessions');
25
- let entries = [];
26
- try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
27
- for (const name of entries) {
28
- const record = readJson(path.join(registry, name));
29
- if (record?.sessionId === sessionId) return record;
30
- }
31
- return null;
32
- }
33
-
34
- /** Whether that session is working on something right now: true, false, or null when it cannot be told.
35
- * This is Claude Code's own bookkeeping, not a published interface: an unknown value answers null rather
36
- * than guessing, and the room falls back to what the conversation says about its own replies. */
37
- export function sessionWorking(sessionId) {
38
- const status = sessionRecord(sessionId)?.status;
39
- if (status === 'busy') return true;
40
- if (status === 'idle' || status === 'ready' || status === 'waiting') return false;
41
- return null;
42
- }
43
-
44
- /** The pid of the session with this id, from Claude Code's own session registry. */
45
- function sessionPid(sessionId) {
46
- const registry = path.join(configDir(), 'sessions');
47
- let entries = [];
48
- try { entries = readdirSync(registry).filter(name => name.endsWith('.json')); } catch { return null; }
49
- for (const name of entries) {
50
- const record = readJson(path.join(registry, name));
51
- if (record?.sessionId === sessionId) return record.pid ?? null;
52
- }
53
- return null;
54
- }
55
-
56
- /** The launch arguments of a pid, via ps so this works the same on Linux and macOS. */
57
- function launchArgs(pid) {
58
- if (!pid) return '';
59
- try { return execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 4000 }).trim(); }
60
- catch { return ''; }
61
- }
62
-
63
- function flag(args, name) {
64
- const match = args.match(new RegExp(`${name}[= ]('[^']*'|"[^"]*"|\\S+)`));
65
- if (!match) return undefined;
66
- return match[1].replace(/^['"]|['"]$/g, '');
67
- }
68
-
69
- /** `--settings` takes inline JSON or a path to a file; both may carry crossSessionInbound. */
70
- function settingsFromFlag(args) {
71
- const value = flag(args, '--settings');
72
- if (!value) return null;
73
- if (value.trim().startsWith('{')) { try { return JSON.parse(value); } catch { return null; } }
74
- return readJson(value);
75
- }
76
-
77
- const BYPASS_MODES = new Set(['bypassPermissions']);
78
-
79
- /** Which model this conversation runs, read from the session's own launch line — no model is asked to
80
- * say what it is. Absent rather than guessed when the launcher did not name one (the CLI's default). */
81
- export function sessionEngine(sessionId) {
82
- const args = launchArgs(sessionPid(sessionId));
83
- if (!args) return null;
84
- const model = flag(args, '--model'), effort = flag(args, '--effort'), thinking = flag(args, '--thinking');
85
- if (!model && !effort) return null;
86
- return { model: model || null, effort: effort || null, thinking: thinking || null };
87
- }
88
-
89
- /** Will an injected message be delivered to this Claude session, or held for its user? */
90
- export function inspectInbound(sessionId) {
91
- const pid = sessionPid(sessionId);
92
- const args = launchArgs(pid);
93
- const user = readJson(path.join(configDir(), 'settings.json')) || {};
94
- const flagged = settingsFromFlag(args) || {};
95
- const mode = flag(args, '--permission-mode') || user.permissions?.defaultMode || 'default';
96
- // Launch flags beat user settings; a managed policy layer could still tighten either.
97
- const inbound = flagged.crossSessionInbound ?? user.crossSessionInbound;
98
- const bypassing = BYPASS_MODES.has(mode);
99
- if (!bypassing) return { ok: true, mode, crossSessionInbound: inbound ?? null };
100
- if (inbound === 'accept') return { ok: true, mode, crossSessionInbound: inbound };
101
- return {
102
- ok: false,
103
- mode,
104
- crossSessionInbound: inbound ?? null,
105
- reason: inbound === 'refuse'
106
- ? 'This session refuses messages from other local processes (crossSessionInbound is "refuse").'
107
- : 'This session bypasses permission prompts, so Claude Code holds messages from other local '
108
- + 'processes for the user to approve instead of delivering them, and it sends no receipt to '
109
- + 'say so. Voice will appear to be sent and nothing will arrive.',
110
- remedy: inbound === 'refuse'
111
- ? 'Change crossSessionInbound from "refuse" to "accept", or start the session in a permission '
112
- + 'mode that prompts.'
113
- : 'Two ways out. Per session: start it with --settings \'{"crossSessionInbound":"accept"}\'. '
114
- + 'For every session on this machine: add "crossSessionInbound": "accept" to '
115
- + `${path.join(configDir(), 'settings.json')} — that takes effect immediately, releases any `
116
- + 'messages already held, and also lets any other local process post into all your sessions, '
117
- + 'which is the safeguard it removes. Or run the conversation in a prompting mode such as '
118
- + '--permission-mode auto.',
119
- // Said plainly so nothing downstream reports this as certain.
120
- confidence: pid ? 'read from the session launch flags and settings' : 'settings only; the session process was not found',
121
- };
122
- }
123
-
124
- /** Identity and private inbox inherited by the MCP façade Claude Code spawned. */
125
- export function sessionIdentity({ env = process.env } = {}) {
126
- if (!env.CLAUDE_CODE_SESSION_ID) return null;
127
- return {
128
- harness: 'claude',
129
- thread: env.CLAUDE_CODE_SESSION_ID,
130
- ...(env.CLAUDE_CODE_MESSAGING_SOCKET ? { delivery: {
131
- kind: 'claude-uds',
132
- socket: env.CLAUDE_CODE_MESSAGING_SOCKET,
133
- token: env.CLAUDE_CODE_MESSAGING_TOKEN || '',
134
- } } : {}),
135
- };
136
- }
137
-
138
- /** Claude Code's session inbox sends no acknowledgement. The read receipt comes from watching the
139
- * session's own transcript (see observe); this method reports only what the socket itself proves. */
140
- export function deliver(delivery, event) {
141
- if (delivery?.kind !== 'claude-uds') throw new Error(`Unsupported Claude delivery kind: ${delivery?.kind}`);
142
- return new Promise((resolve, reject) => {
143
- const started = Date.now();
144
- const socket = net.createConnection(delivery.socket);
145
- let settled = false, wrote = 0, replied = '';
146
- const finish = (error, status, detail) => {
147
- if (settled) return;
148
- settled = true; clearTimeout(timer); socket.destroy();
149
- if (error) return reject(error);
150
- resolve({ status, detail: `${detail} after ${Date.now() - started}ms${replied ? ', peer said ' + replied.slice(0, 120) : ''}` });
151
- };
152
- const timer = setTimeout(() => finish(null, 'unknown', 'connection still open, no acknowledgement'), 1500);
153
- socket.on('error', error => finish(error));
154
- socket.on('data', chunk => { replied += chunk; });
155
- socket.on('connect', () => {
156
- socket.write(JSON.stringify({ type: 'auth', token: delivery.token }) + '\n');
157
- socket.write(JSON.stringify({ type: 'user', message: { role: 'user', content: envelope(event) } }) + '\n');
158
- wrote = Date.now();
159
- });
160
- socket.on('close', () => finish(null, 'rejected', wrote ? 'peer closed the connection' : 'peer closed before the frames were written'));
161
- });
162
- }
163
-
164
- /** The transcript Claude Code writes for a session: one JSON-lines file named after the session id, under
165
- * the project directory it derives from the launch cwd. Found by name rather than derived, so a renamed
166
- * or moved cwd changes nothing. */
167
- export function transcriptPath(sessionId) {
168
- const projects = path.join(configDir(), 'projects');
169
- let dirs = [];
170
- try { dirs = readdirSync(projects); } catch { return null; }
171
- for (const dir of dirs) {
172
- const candidate = path.join(projects, dir, sessionId + '.jsonl');
173
- if (existsSync(candidate)) return candidate;
174
- }
175
- return null;
176
- }
177
-
178
- /** The text of a transcript entry that is a user message, or null for anything else (tool results,
179
- * attachments, the session's own bookkeeping). */
180
- export function userMessageText(entry) {
181
- if (entry?.type !== 'user' || entry.message?.role !== 'user') return null;
182
- const content = entry.message.content;
183
- if (typeof content === 'string') return content;
184
- if (!Array.isArray(content)) return null;
185
- const parts = content.filter(part => part?.type === 'text').map(part => part.text);
186
- return parts.length ? parts.join('\n') : null;
187
- }
188
-
189
- const POLL_MS = Number(process.env.SIDEVOICE_WORK_POLL_MS || 400);
190
-
191
- /** Watch one session through what Claude Code itself writes about it, and nothing installed in it:
192
- * its registry record says whether it is busy, and its transcript records every user message the
193
- * moment the session admits it (a message from the inbox is appended as the turn takes it, not when
194
- * the socket accepted it — the difference is what the second tick shows). */
195
- export function observe(sessionId, handlers) {
196
- let lastStatus = null;
197
- const stopTranscript = tailJsonl(() => transcriptPath(sessionId), entry => {
198
- const text = userMessageText(entry);
199
- if (text !== null) handlers.userMessage({ text, turn_id: entry.promptId || null });
200
- }, { intervalMs: POLL_MS });
201
- const timer = setInterval(() => {
202
- const working = sessionWorking(sessionId);
203
- if (working === null || working === lastStatus) return;
204
- lastStatus = working;
205
- handlers.working(working, {});
206
- }, POLL_MS);
207
- timer.unref?.();
208
- return () => { clearInterval(timer); stopTranscript(); };
209
- }
210
-
211
- export const claudeHarness = defineHarness({
212
- name: 'claude',
213
- capabilities: {
214
- deliver: SUPPORTED,
215
- inspectInbound: SUPPORTED,
216
- working: SUPPORTED,
217
- endOfTurn: SUPPORTED,
218
- sessionIdentity: SUPPORTED,
219
- },
220
- deliver,
221
- inspectInbound,
222
- engine: sessionEngine,
223
- observe,
224
- sessionIdentity,
225
- });
226
-
227
- export default claudeHarness;
package/harness-codex.mjs DELETED
@@ -1,126 +0,0 @@
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;
@@ -1,116 +0,0 @@
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
- const DECLARED_STATES = new Set([SUPPORTED, UNSUPPORTED]);
16
-
17
- export function capabilityState(harness, capability) {
18
- const state = harness?.capabilities?.[capability];
19
- return DECLARED_STATES.has(state) ? state : UNKNOWN;
20
- }
21
-
22
- export function advertisedCapabilities(harness) {
23
- return Object.fromEntries(CAPABILITIES.map(capability => [capability, capabilityState(harness, capability)]));
24
- }
25
-
26
- /** `working` and `endOfTurn` are both answered by observation: a harness that supports either
27
- * implements `observe(thread, handlers)`, which watches what the harness itself writes about that
28
- * conversation and calls back — `working(bool, { turn_id })` on every transition it can see, and
29
- * `userMessage({ text, turn_id })` for every user message the conversation admits. It returns a
30
- * function that stops watching. Nothing is installed in the harness for this to work. */
31
- export function defineHarness(definition) {
32
- if (!definition?.name) throw new Error('A harness needs a name');
33
- for (const capability of CAPABILITIES) {
34
- const state = definition.capabilities?.[capability];
35
- if (!DECLARED_STATES.has(state)) throw new Error(`${definition.name} must declare ${capability}`);
36
- const implemented = capability === 'working' || capability === 'endOfTurn' ? definition.observe : definition[capability];
37
- if (state === SUPPORTED && typeof implemented !== 'function') {
38
- throw new Error(`${definition.name} declares ${capability} supported but does not implement it`);
39
- }
40
- }
41
- return Object.freeze({ ...definition, capabilities: Object.freeze({ ...definition.capabilities }) });
42
- }
43
-
44
- /** The header before the user's literal words, and — for a voice message — the note after them that
45
- * asks the conversation to speak first. The note travels inside the message because it is the only
46
- * thing every harness delivers without anything installed; the instructions name it as not the user's. */
47
- export function envelope(event) {
48
- const header = {
49
- channel: event.channel === 'room-control' ? 'room-control' : 'voice',
50
- session_id: event.session_id,
51
- revision: event.revision,
52
- message_id: event.message_id,
53
- };
54
- const note = header.channel === 'voice' ? '\n\n' + nudge(header) : '';
55
- return JSON.stringify(header) + '\n\n' + event.text + note;
56
- }
57
-
58
- /** What the conversation is asked at the moment it reads a voice message: one line that points at the
59
- * server's instructions, which are already in context — not a copy of them. The harness adds its own
60
- * wrapper around a cross-session message; ours stays small. */
61
- export function nudge(header) {
62
- return `[Sidevoice] Voice from the room: acknowledge with voice_say (session_id "${header.session_id}", revision ${header.revision}) before any other tool, then work and reply by voice, as the sidevoice server's instructions say.`;
63
- }
64
-
65
- /** The header at the front of a delivered message, or null when the text is not one of ours. Works on
66
- * the text as a harness stores it, which may put its own line before the header. */
67
- export function voiceEnvelope(text) {
68
- if (typeof text !== 'string') return null;
69
- const start = text.indexOf('{"channel":');
70
- if (start < 0) return null;
71
- const end = text.indexOf('}', start);
72
- if (end < 0) return null;
73
- let header;
74
- try { header = JSON.parse(text.slice(start, end + 1)); } catch { return null; }
75
- if (!header || (header.channel !== 'voice' && header.channel !== 'room-control') || !header.message_id || !header.session_id || !Number.isInteger(header.revision)) return null;
76
- return { channel: header.channel, message_id: header.message_id, session_id: header.session_id, revision: header.revision };
77
- }
78
-
79
- /** Follows a JSON-lines file the harness appends to: each complete new line is handed to `onLine` as
80
- * parsed JSON. From where the file is now, unless `catchUp` is set — then what is already there is
81
- * read first, with `replayed = true`, so a watcher can learn the current state without mistaking old
82
- * lines for news. The file may not exist yet — `locate` is asked again until it does. */
83
- export function tailJsonl(locate, onLine, { intervalMs = 400, catchUp = false, caughtUp = () => {} } = {}) {
84
- let file = null, offset = null, remainder = '', replaying = false;
85
- const poll = async () => {
86
- const { statSync, openSync, readSync, closeSync } = await import('node:fs');
87
- if (!file) { file = locate(); if (!file) return; }
88
- let size;
89
- try { size = statSync(file).size; } catch { file = null; offset = null; return; }
90
- if (offset === null) {
91
- if (!catchUp) { offset = size; return; } // start at the end: only what happens from now on
92
- offset = 0; replaying = true;
93
- }
94
- if (size < offset) { offset = 0; remainder = ''; } // rewritten: read it again from the top
95
- if (size === offset) return;
96
- const fd = openSync(file, 'r');
97
- try {
98
- const buffer = Buffer.alloc(size - offset);
99
- readSync(fd, buffer, 0, buffer.length, offset);
100
- offset = size;
101
- remainder += buffer.toString('utf8');
102
- } finally { closeSync(fd); }
103
- let index;
104
- while ((index = remainder.indexOf('\n')) >= 0) {
105
- const line = remainder.slice(0, index); remainder = remainder.slice(index + 1);
106
- if (!line.trim()) continue;
107
- let parsed; try { parsed = JSON.parse(line); } catch { continue; }
108
- try { onLine(parsed, replaying); } catch {}
109
- }
110
- if (replaying) { replaying = false; try { caughtUp(); } catch {} }
111
- };
112
- const timer = setInterval(() => { poll().catch(() => {}); }, intervalMs);
113
- timer.unref?.();
114
- poll().catch(() => {});
115
- return () => clearInterval(timer);
116
- }
package/harness-http.mjs DELETED
@@ -1,39 +0,0 @@
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 DELETED
@@ -1,19 +0,0 @@
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
- }