@sidevoice/uplink 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -14
- package/dist/cli.mjs +10512 -0
- package/dist/package.json +32 -0
- package/package.json +10 -14
- package/cli.mjs +0 -10
- package/connector.mjs +0 -322
- package/harness-claude.mjs +0 -227
- package/harness-codex.mjs +0 -126
- package/harness-contract.mjs +0 -116
- package/harness-http.mjs +0 -39
- package/harnesses.mjs +0 -19
- package/install.mjs +0 -212
- package/mcp.mjs +0 -198
- package/pair.mjs +0 -65
- package/skill/voice-room/SKILL.md +0 -16
- package/skill.mjs +0 -57
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;
|
package/harness-contract.mjs
DELETED
|
@@ -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. */
|
|
59
|
-
export function nudge(header) {
|
|
60
|
-
return `[Sidevoice] A voice message from the room (session_id "${header.session_id}", revision ${header.revision}). `
|
|
61
|
-
+ 'Before any other tool, publish a short spoken acknowledgement with voice_say that says what you understood and what you will do next, '
|
|
62
|
-
+ 'using that session_id and revision; then continue the work and publish the result by voice as well.';
|
|
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
|
-
}
|
package/install.mjs
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** `sidevoice install` — put this version of Sidevoice in front of the harnesses on this machine.
|
|
3
|
-
*
|
|
4
|
-
* It registers the MCP server (re-pinned to this version when an older one was registered), installs
|
|
5
|
-
* the skill, says what it changed, and is safe to run twice: run it again after an upgrade and the
|
|
6
|
-
* harness points at the new version. It pairs with nothing. Pairing is a person's act — the room shows
|
|
7
|
-
* a one-time code to whoever is in it, and a conversation asks for it the first time it joins — so
|
|
8
|
-
* the installer only reports whether this machine is paired, and with which room.
|
|
9
|
-
*
|
|
10
|
-
* What it does not do is decide for the person: it never edits a machine-wide Codex configuration it
|
|
11
|
-
* does not own, and it never relaxes Claude Code's inbound safeguard — those it prints, with the reason. */
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
14
|
-
import net from 'node:net';
|
|
15
|
-
import os from 'node:os';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { pairedRoom } from './pair.mjs';
|
|
19
|
-
import { install as installSkill, skillsDir } from './skill.mjs';
|
|
20
|
-
|
|
21
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const VERSION = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).version;
|
|
23
|
-
/** The files that make up this package, copied as they are. */
|
|
24
|
-
const PACKAGE_FILES = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).files.concat('package.json');
|
|
25
|
-
|
|
26
|
-
function fromSource(env) {
|
|
27
|
-
if (env.SIDEVOICE_INSTALL_FROM_SOURCE === '0') return false;
|
|
28
|
-
return env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Where installed copies live: one directory per version, under the XDG data home. */
|
|
32
|
-
export function copiesDir(env = process.env) {
|
|
33
|
-
return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'sidevoice');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** What a harness should run to start the server. From a checkout it names that checkout, so a machine that
|
|
37
|
-
* installed from source keeps working when the published version moves. Otherwise it names a copy of this
|
|
38
|
-
* package that install placed on disk — never `npx`: a session start is not the moment to resolve a package
|
|
39
|
-
* (a cold cache, a bin whose name differs from the package's, a 30 s startup budget; one session found no
|
|
40
|
-
* `sidevoice` binary at all, 2026-09-21). */
|
|
41
|
-
export function serverCommand(env = process.env) {
|
|
42
|
-
const cli = fromSource(env) ? path.join(here, 'cli.mjs') : path.join(copiesDir(env), VERSION, 'cli.mjs');
|
|
43
|
-
return { command: 'node', args: [cli, 'mcp'] };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Put this version's files where serverCommand points, and drop the other versions: an installed copy is
|
|
47
|
-
* disposable and there is one current one. From a checkout nothing is copied. */
|
|
48
|
-
export function materialize(env = process.env) {
|
|
49
|
-
if (fromSource(env)) return { action: 'checkout', target: here };
|
|
50
|
-
const root = copiesDir(env), target = path.join(root, VERSION);
|
|
51
|
-
mkdirSync(target, { recursive: true });
|
|
52
|
-
for (const file of PACKAGE_FILES) {
|
|
53
|
-
const source = path.join(here, file);
|
|
54
|
-
if (existsSync(source)) cpSync(source, path.join(target, file), { recursive: true });
|
|
55
|
-
}
|
|
56
|
-
const removed = [];
|
|
57
|
-
for (const name of readdirSync(root)) {
|
|
58
|
-
if (name !== VERSION) { rmSync(path.join(root, name), { recursive: true, force: true }); removed.push(name); }
|
|
59
|
-
}
|
|
60
|
-
return { action: 'copied', target, removed };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The connector that holds this machine's socket, if any, and which version it is: a façade uses whatever
|
|
64
|
-
* connector is running, so one left over from before an upgrade serves every new session with old code. */
|
|
65
|
-
export function runningConnector(env = process.env) {
|
|
66
|
-
const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
|
|
67
|
-
const socketPath = env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
|
|
68
|
-
let pid = null;
|
|
69
|
-
try { pid = Number(readFileSync(socketPath + '.lock', 'utf8')) || null; } catch { return null; }
|
|
70
|
-
return new Promise(resolve => {
|
|
71
|
-
const socket = net.createConnection(socketPath);
|
|
72
|
-
const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
|
|
73
|
-
const timer = setTimeout(() => done(null), 1500);
|
|
74
|
-
let buffer = '';
|
|
75
|
-
socket.on('error', () => done(null));
|
|
76
|
-
socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method: 'status', params: {} }) + '\n'));
|
|
77
|
-
socket.on('data', chunk => {
|
|
78
|
-
buffer += chunk; const index = buffer.indexOf('\n'); if (index < 0) return;
|
|
79
|
-
try { const reply = JSON.parse(buffer.slice(0, index)); done({ pid, version: reply.result?.version || null, bindings: reply.result?.bindings?.length ?? null }); }
|
|
80
|
-
catch { done({ pid, version: null, bindings: null }); }
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function flag(argv, name) {
|
|
86
|
-
const index = argv.indexOf(name);
|
|
87
|
-
return index >= 0 ? argv[index + 1] : undefined;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Which harnesses this machine has, by what they leave behind. */
|
|
91
|
-
export function harnessesPresent(env = process.env) {
|
|
92
|
-
const found = [];
|
|
93
|
-
if (existsSync(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'))) found.push('claude');
|
|
94
|
-
if (existsSync(env.CODEX_HOME || path.join(os.homedir(), '.codex'))) found.push('codex');
|
|
95
|
-
return found;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function claude(args, env) {
|
|
99
|
-
return execFileSync(env.SIDEVOICE_CLAUDE_BIN || 'claude', args, { encoding: 'utf8', timeout: 30_000, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** What Claude Code currently runs for `sidevoice`, read from its own `mcp get`: null when nothing is
|
|
103
|
-
* registered or there is no `claude` to ask. The scope matters — only a user-scope entry is ours to move. */
|
|
104
|
-
export function claudeRegistration(env = process.env) {
|
|
105
|
-
let output;
|
|
106
|
-
try { output = claude(['mcp', 'get', 'sidevoice'], env); } catch { return null; }
|
|
107
|
-
const field = name => (output.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, 'm')) || [])[1]?.trim() ?? '';
|
|
108
|
-
const command = field('Command'), args = field('Args');
|
|
109
|
-
if (!command) return null;
|
|
110
|
-
return { scope: /user/i.test(field('Scope')) ? 'user' : 'other', line: [command, args].filter(Boolean).join(' ') };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function registerWithClaude(done, env) {
|
|
114
|
-
const { command, args } = serverCommand(env);
|
|
115
|
-
const wanted = [command, ...args].join(' ');
|
|
116
|
-
const manual = `claude mcp add --scope user sidevoice -- ${wanted}`;
|
|
117
|
-
const current = claudeRegistration(env);
|
|
118
|
-
if (current?.line === wanted) { done.push('Claude Code already runs this version of the MCP server.'); return; }
|
|
119
|
-
if (current && current.scope !== 'user') {
|
|
120
|
-
done.push(`Claude Code has a sidevoice MCP server registered outside user scope (${current.line}); not touched. To move it:\n ${manual}`);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
if (current) claude(['mcp', 'remove', '--scope', 'user', 'sidevoice'], env);
|
|
125
|
-
claude(['mcp', 'add', '--scope', 'user', 'sidevoice', '--', command, ...args], env);
|
|
126
|
-
done.push(current ? `Re-pointed Claude Code's MCP server to this version (was: ${current.line}).`
|
|
127
|
-
: 'Registered the MCP server with Claude Code (user scope).');
|
|
128
|
-
} catch (error) {
|
|
129
|
-
done.push(`Could not register with Claude Code automatically (${(error.message || '').split('\n')[0]}). Run:\n ${manual}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Codex keeps one machine-wide file that may hold anything its user put there: we never rewrite it. */
|
|
134
|
-
export function codexInstructions(env = process.env) {
|
|
135
|
-
const { command, args } = serverCommand(env);
|
|
136
|
-
return [
|
|
137
|
-
`Add to ${env.CODEX_HOME || path.join(os.homedir(), '.codex')}/config.toml — it is machine-wide and`,
|
|
138
|
-
'this package does not rewrite it:',
|
|
139
|
-
'',
|
|
140
|
-
' [mcp_servers.sidevoice]',
|
|
141
|
-
` command = "${command}"`,
|
|
142
|
-
` args = [${args.map(a => `"${a}"`).join(', ')}]`,
|
|
143
|
-
'',
|
|
144
|
-
'Then restart Codex. That is all: read receipts and working state come from what Codex records about the thread.',
|
|
145
|
-
].join('\n');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Claude Code holds messages from other local processes when a session bypasses permission prompts. */
|
|
149
|
-
export function inboundWarning(env = process.env) {
|
|
150
|
-
const settings = path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
|
|
151
|
-
let parsed = {};
|
|
152
|
-
try { parsed = JSON.parse(readFileSync(settings, 'utf8')); } catch { return null; }
|
|
153
|
-
if (parsed.crossSessionInbound) return null;
|
|
154
|
-
if (parsed.permissions?.defaultMode !== 'bypassPermissions') return null;
|
|
155
|
-
return [
|
|
156
|
-
'This machine runs Claude Code sessions in bypassPermissions, and those hold what the room sends',
|
|
157
|
-
'instead of delivering it — voice looks sent and never arrives. Either start a session with',
|
|
158
|
-
` --settings '{"crossSessionInbound":"accept"}'`,
|
|
159
|
-
`or add "crossSessionInbound": "accept" to ${settings}. That second one lets any local process post`,
|
|
160
|
-
'into every Claude session on this machine, which is the safeguard it removes: your call, not ours.',
|
|
161
|
-
].join('\n');
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export async function install(argv = process.argv.slice(2), env = process.env) {
|
|
165
|
-
const stray = argv.find(item => !item.startsWith('-') && argv[argv.indexOf(item) - 1] !== '--harness');
|
|
166
|
-
if (stray) throw new Error(`usage: sidevoice install [--harness claude|codex]\n` +
|
|
167
|
-
`Pairing is not part of installing: a conversation asks for the room's code the first time it joins, ` +
|
|
168
|
-
`or run sidevoice pair <room-url> <code> with the code the room shows under "Emparejar conector".`);
|
|
169
|
-
const wanted = flag(argv, '--harness');
|
|
170
|
-
const harnesses = wanted ? [wanted] : harnessesPresent(env);
|
|
171
|
-
const done = [], next = [];
|
|
172
|
-
|
|
173
|
-
done.push(`Sidevoice ${VERSION}.`);
|
|
174
|
-
const copy = materialize(env);
|
|
175
|
-
if (copy.action === 'copied') done.push(`Copied this version to ${copy.target}${copy.removed.length ? ` (removed: ${copy.removed.join(', ')})` : ''}.`);
|
|
176
|
-
if (harnesses.includes('claude')) {
|
|
177
|
-
registerWithClaude(done, env);
|
|
178
|
-
const outcome = installSkill(skillsDir([], env));
|
|
179
|
-
done.push(`Skill ${outcome.action} at ${outcome.target}.`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const paired = pairedRoom(env);
|
|
183
|
-
done.push(paired ? `This machine is paired with ${paired.origin} (connector ${paired.connector_id}).`
|
|
184
|
-
: 'This machine is not paired with any room yet.');
|
|
185
|
-
const running = await runningConnector(env);
|
|
186
|
-
if (running && running.version !== VERSION) {
|
|
187
|
-
next.push(`A connector from ${running.version ? 'version ' + running.version : 'an older version'} is still running (pid ${running.pid}) and every conversation on this machine uses it. ` +
|
|
188
|
-
`It exits by itself 15 s after the last conversation leaves it; to switch now: kill ${running.pid}, then join again from each conversation.`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (harnesses.includes('claude')) {
|
|
192
|
-
next.push('In a conversation, run /voice-room to join the room.' +
|
|
193
|
-
(paired ? '' : ' The first time, the conversation asks you for the room\'s address and the one-time code the room shows under "Emparejar conector".'));
|
|
194
|
-
next.push('Sessions already open need a restart before they can see the skill.');
|
|
195
|
-
const warning = inboundWarning(env);
|
|
196
|
-
if (warning) next.push(warning);
|
|
197
|
-
}
|
|
198
|
-
if (harnesses.includes('codex')) next.push(codexInstructions(env));
|
|
199
|
-
if (!harnesses.length) next.push('No harness found on this machine. Pass --harness claude or --harness codex.');
|
|
200
|
-
return { done, next };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (process.env.SIDEVOICE_INSTALL_MAIN === '1') {
|
|
204
|
-
try {
|
|
205
|
-
const { done, next } = await install();
|
|
206
|
-
for (const line of done) console.log('· ' + line);
|
|
207
|
-
if (next.length) {
|
|
208
|
-
console.log('\nLeft for you:');
|
|
209
|
-
for (const line of next) console.log('\n' + line);
|
|
210
|
-
}
|
|
211
|
-
} catch (error) { console.error(error.message); process.exit(1); }
|
|
212
|
-
}
|