nomoremeatproxy 0.0.0-stage → 0.1.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/src/ledger.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // Append-only delivery ledger. Holds ids, states and message hashes, never message text.
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { checkStateDir, writeState } from './writable.mjs';
6
+
7
+ export function defaultStateDir(env = process.env, platform = process.platform, home = os.homedir()) {
8
+ if (env.NOMEAT_STATE_DIR) return env.NOMEAT_STATE_DIR;
9
+ // Not under AppData: Windows gives every Store app a private copy of that folder, so the
10
+ // two desktop apps would each keep a ledger the other cannot see.
11
+ if (platform === 'win32') return path.join(home, '.nomoremeatproxy');
12
+ const stateHome = env.XDG_STATE_HOME ?? path.join(home, '.local', 'state');
13
+ return path.join(stateHome, 'nomoremeatproxy');
14
+ }
15
+
16
+ function parseLine(line) {
17
+ try {
18
+ return JSON.parse(line);
19
+ } catch {
20
+ // A crash can leave a torn last line; the entries before it are still valid.
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export function openLedger(stateDir, { now = () => new Date(), readOnly = false } = {}) {
26
+ const file = path.join(stateDir, 'ledger.jsonl');
27
+ const readAll = () => {
28
+ if (!readOnly && !existsSync(file)) {
29
+ checkStateDir(stateDir);
30
+ return [];
31
+ }
32
+ let contents;
33
+ try {
34
+ contents = readFileSync(file, 'utf8');
35
+ } catch (error) {
36
+ // existsSync also returns false for denied paths. A read-only report must not turn
37
+ // unknown routes into "none", or probe for writes while checking a missing ledger.
38
+ if (readOnly && error.code === 'ENOENT') return [];
39
+ throw error;
40
+ }
41
+ return contents.split('\n').filter(Boolean).map(parseLine).filter(Boolean);
42
+ };
43
+ return {
44
+ file,
45
+ append(event) {
46
+ if (readOnly) throw new Error('this ledger is read-only');
47
+ const entry = { at: now().toISOString(), ...event };
48
+ writeState(() => {
49
+ mkdirSync(stateDir, { recursive: true });
50
+ appendFileSync(file, `${JSON.stringify(entry)}\n`);
51
+ });
52
+ return entry;
53
+ },
54
+ entries() {
55
+ return readAll();
56
+ },
57
+ history(requestId) {
58
+ return readAll().filter((entry) => entry.requestId === requestId);
59
+ },
60
+ // For an answer that found nothing: names the folder read, as the other side may use another.
61
+ notFound(what) {
62
+ return { stateDir, note: `${what} in this state folder; both sides must use the same one (NOMEAT_STATE_DIR)` };
63
+ },
64
+ // The thread of the most recent answered call between this caller and peer in this project.
65
+ // A chat that answered while attended in its app is left out: continuing it later would
66
+ // mean a headless run on a session the app may still hold. So is the answer to a call the
67
+ // caller steered into a new or an exact thread, recovered or not: such a thread must not
68
+ // become the default by being recent.
69
+ lastThread({ project, from, peer }) {
70
+ const entries = readAll();
71
+ const steered = new Set(entries.filter((entry) => entry.steered).map((entry) => entry.requestId));
72
+ const entry = entries.findLast(
73
+ (candidate) =>
74
+ candidate.state === 'answered' &&
75
+ candidate.threadId &&
76
+ candidate.delivery !== 'attended' &&
77
+ !steered.has(candidate.requestId) &&
78
+ candidate.project === project &&
79
+ candidate.from === from &&
80
+ candidate.peer === peer,
81
+ );
82
+ return entry?.threadId ?? null;
83
+ },
84
+ // The thread named as the standing conversation between this caller and peer in this
85
+ // project. Set explicitly or by the first call; a side thread never changes it.
86
+ standingThread({ project, from, peer }) {
87
+ const entry = readAll().findLast(
88
+ (candidate) => candidate.state === 'standing' && candidate.project === project && candidate.from === from && candidate.peer === peer,
89
+ );
90
+ return entry?.threadId ?? null;
91
+ },
92
+ // The access the bridge recorded for a thread. The earliest record wins on purpose: it is
93
+ // what the thread was created with, and a later resume with write may not have stuck.
94
+ threadAccess(threadId) {
95
+ if (!threadId) return null;
96
+ return readAll().find((entry) => entry.threadId === threadId && entry.access)?.access ?? null;
97
+ },
98
+ // The newest call from this caller to this peer that is still waiting for its answer:
99
+ // submitted, not answered, not failed, not sent without waiting, and recent enough to
100
+ // still be running.
101
+ openCall({ project, from, peer, maxAgeMs }) {
102
+ const entries = readAll();
103
+ const closed = new Set(entries.filter((entry) => ['answered', 'failed', 'sent'].includes(entry.state)).map((entry) => entry.requestId));
104
+ const oldest = now().getTime() - maxAgeMs;
105
+ return (
106
+ entries.findLast(
107
+ (entry) =>
108
+ entry.state === 'submitting' &&
109
+ entry.project === project &&
110
+ entry.from === from &&
111
+ entry.peer === peer &&
112
+ !closed.has(entry.requestId) &&
113
+ Date.parse(entry.at) >= oldest,
114
+ ) ?? null
115
+ );
116
+ },
117
+ };
118
+ }
package/src/listen.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // The listening loop a Claude chat runs in the desktop app: keep a heartbeat so callers know
2
+ // the chat is attended, and emit each incoming request once. Also the line printed for it.
3
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+
5
+ // Every request line says how to show it, so the owner sees the incoming message even when
6
+ // the chat no longer remembers the rule. The instruction comes before the message, which
7
+ // comes last because a long notification is cut off at the end. Kept short: it is paid for
8
+ // on every request.
9
+ export function requestLine({ requestId, from, hop, maxHops, file, message }) {
10
+ const instruction = `Before anything else you write in the chat, quote the whole message and name ${from} as its sender. If message is cut off in this line, read all of it from file first. Then write your answer, then send that same answer with: nomeat reply --request-id ${requestId}`;
11
+ return { requestId, from, hop, maxHops, file, instruction, message };
12
+ }
13
+
14
+ // `holdClaim`, asked when the loop ends without an error, says whether to leave the claim live
15
+ // for the chat's next start (`listen --once`) instead of releasing it.
16
+ export async function runListener({ mailbox, box, session, emit, onHeartbeat = () => {}, stopped = () => false, holdClaim = () => false, sleep = wait, now = () => Date.now(), pollMs = 1000, beatMs = 5000 }) {
17
+ const lease = mailbox.claimListener(box, session);
18
+ let hold = false;
19
+ try {
20
+ mailbox.purge();
21
+ let lastBeat = -Infinity;
22
+ while (!stopped()) {
23
+ const renew = now() - lastBeat >= beatMs;
24
+ const requests = lease.poll(renew);
25
+ if (requests === null) break;
26
+ if (renew) lastBeat = now();
27
+ for (const request of requests) emit(request);
28
+ if (renew) onHeartbeat();
29
+ await sleep(pollMs);
30
+ }
31
+ hold = holdClaim();
32
+ } finally {
33
+ if (hold) lease.hold();
34
+ else lease.release();
35
+ }
36
+ }
@@ -0,0 +1,88 @@
1
+ // One live listener per project/peer on this machine. The JSON heartbeat stays readable
2
+ // by existing callers. A SQLite writer lock serializes the check and file operations across
3
+ // processes; the OS releases it on crash. No lock is held while a listener sleeps or emits.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { mkdirSync, readFileSync, rmSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const LISTENER_MAX_AGE_MS = 20_000;
9
+ // How long a claim stays live after `listen --once` exits, while the chat finishes its turn.
10
+ export const HELD_CLAIM_MS = 5 * 60_000;
11
+
12
+ function heartbeat(file) {
13
+ try {
14
+ return JSON.parse(readFileSync(file, 'utf8'));
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return null;
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ export function liveListener(file, now) {
22
+ // Discovery is advisory: as before the guard, an unreadable heartbeat means no
23
+ // known listener. Writers use the strict read under the lock and never guess ownership.
24
+ let beat;
25
+ try {
26
+ beat = heartbeat(file);
27
+ } catch {
28
+ return null;
29
+ }
30
+ return beat && now() - beat.at <= LISTENER_MAX_AGE_MS ? { session: beat.session } : null;
31
+ }
32
+
33
+ function locked(file, operation) {
34
+ // Load the built-in only on the listener path. Node 22.15+ ships it; no npm install.
35
+ const DatabaseSync = process.getBuiltinModule('node:sqlite')?.DatabaseSync;
36
+ if (!DatabaseSync) throw new Error('listening requires a Node.js 22.15+ build with built-in SQLite');
37
+ const db = new DatabaseSync(file);
38
+ try {
39
+ db.exec('PRAGMA busy_timeout=5000; BEGIN IMMEDIATE');
40
+ return operation();
41
+ } finally {
42
+ db.close(); // ends the lock-only transaction, including when operation throws
43
+ }
44
+ }
45
+
46
+ export function claimListener({ file, peer, session = null, now, writeJson, collect }) {
47
+ mkdirSync(path.dirname(file), { recursive: true });
48
+ const exclusive = (fn) => locked(`${file}.lock.sqlite`, fn);
49
+ const run = randomUUID();
50
+ const beat = (ahead = 0) => writeJson(file, { session, run, at: now() + ahead });
51
+ const owns = () => heartbeat(file)?.run === run;
52
+
53
+ exclusive(() => {
54
+ const previous = heartbeat(file);
55
+ if (previous && now() - previous.at <= LISTENER_MAX_AGE_MS) {
56
+ // Two anonymous processes cannot establish that they belong to the same chat.
57
+ // A pre-guard listener cannot be fenced, even if it has the same session id.
58
+ if (!session || previous.session !== session || !previous.run) {
59
+ throw new Error(`${peer} already has a listener for this project (${previous.session || 'unknown chat'}); stop it first or wait for its last heartbeat to expire`);
60
+ }
61
+ }
62
+ beat();
63
+ });
64
+
65
+ return {
66
+ poll(renew) {
67
+ return exclusive(() => {
68
+ if (!owns()) return null; // replaced; never refresh or consume another run's work
69
+ if (renew) beat();
70
+ return collect();
71
+ });
72
+ },
73
+ release() {
74
+ exclusive(() => {
75
+ if (owns()) rmSync(file);
76
+ });
77
+ },
78
+ // Leaves the claim live for HELD_CLAIM_MS, so callers keep waiting in the inbox and the
79
+ // same chat's next start (same session) takes it over. The beat is stamped ahead so the
80
+ // usual freshness check expires it then. Limit: a restart later than that lets calls
81
+ // fall back to the standing chat, and a chat that stopped for good holds callers that long.
82
+ hold() {
83
+ exclusive(() => {
84
+ if (owns()) beat(HELD_CLAIM_MS - LISTENER_MAX_AGE_MS);
85
+ });
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,145 @@
1
+ // File mailbox for handing a call to a Claude chat that is listening in the desktop app.
2
+ // Message text lives here only until the reply is collected; the ledger still holds none.
3
+ //
4
+ // Layout under <state>/inbox:
5
+ // listeners/<key>.json heartbeat of the chat attending one peer name in one project
6
+ // requests/<id>.json posted by a caller, not yet taken by the listener
7
+ // delivered/<id>.json taken by the listener, not yet answered
8
+ // replies/<id>.json answered, not yet collected by the caller
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { claimListener, liveListener } from './listener-lease.mjs';
13
+ import { writeState } from './writable.mjs';
14
+
15
+ export { LISTENER_MAX_AGE_MS } from './listener-lease.mjs';
16
+ const STALE_FILE_MS = 24 * 60 * 60 * 1000;
17
+ const STAGES = Object.freeze(['requests', 'delivered', 'replies']);
18
+ const SAFE_NAME = /^[A-Za-z0-9._-]{1,80}$/;
19
+
20
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
21
+ const fileName = (requestId) => `${SAFE_NAME.test(requestId) ? requestId : sha256(requestId)}.json`;
22
+
23
+ // Windows compares paths without case, and the same folder must give one key from any shell.
24
+ export function mailboxKey({ project, peer }, platform = process.platform) {
25
+ const folder = platform === 'win32' ? project.toLowerCase() : project;
26
+ return sha256(`${folder}\n${peer}`).slice(0, 16);
27
+ }
28
+
29
+ // Limit: removals (rmSync) are not wrapped; a folder that refuses them refuses this first.
30
+ function writeJson(file, value) {
31
+ writeState(() => {
32
+ mkdirSync(path.dirname(file), { recursive: true });
33
+ // Written whole and then renamed, so a reader never sees half a file.
34
+ const partial = `${file}.${process.pid}.tmp`;
35
+ writeFileSync(partial, JSON.stringify(value));
36
+ renameSync(partial, file);
37
+ });
38
+ }
39
+
40
+ function readJson(file) {
41
+ try {
42
+ return JSON.parse(readFileSync(file, 'utf8'));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ const listJson = (dir) => (existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith('.json')) : []);
49
+
50
+ export function openMailbox(stateDir, { now = () => Date.now() } = {}) {
51
+ const root = path.join(stateDir, 'inbox');
52
+ const stageFile = (stage, requestId) => path.join(root, stage, fileName(requestId));
53
+ const listenerFile = (box) => path.join(root, 'listeners', `${mailboxKey(box)}.json`);
54
+
55
+ const mailbox = {
56
+ // --- listener side ---
57
+ claimListener(box, session) {
58
+ return claimListener({ file: listenerFile(box), peer: box.peer, session, now, writeJson, collect: () => mailbox.collect(box) });
59
+ },
60
+ // Takes every waiting request addressed to this box, oldest first.
61
+ collect(box) {
62
+ const key = mailboxKey(box);
63
+ const dir = path.join(root, 'requests');
64
+ const waiting = listJson(dir)
65
+ .map((name) => ({ name, request: readJson(path.join(dir, name)) }))
66
+ .filter(({ request }) => request?.key === key)
67
+ .sort((a, b) => a.request.at - b.request.at);
68
+ const taken = [];
69
+ for (const { name, request } of waiting) {
70
+ writeState(() => mkdirSync(path.join(root, 'delivered'), { recursive: true }));
71
+ try {
72
+ const file = path.join(root, 'delivered', name);
73
+ // A repost of an id already taken or answered is dropped, not printed again. Callers
74
+ // refuse a retry of a taken id and remove their repost when they take a waiting
75
+ // answer; this catches a repost that slips in between. Limit: once the answer is
76
+ // collected nothing is left to match, so a resend forced past the ledger is handed
77
+ // over again.
78
+ if (existsSync(file) || existsSync(path.join(root, 'replies', name))) {
79
+ rmSync(path.join(dir, name), { force: true });
80
+ continue;
81
+ }
82
+ renameSync(path.join(dir, name), file);
83
+ // The file is named so a listener whose notice was cut short can read the whole text.
84
+ taken.push({ ...request, file });
85
+ } catch {
86
+ // The caller withdrew it in the meantime.
87
+ }
88
+ }
89
+ return taken;
90
+ },
91
+ answer(requestId, reply) {
92
+ const open = ['requests', 'delivered'].some((stage) => existsSync(stageFile(stage, requestId)));
93
+ if (!open) throw new Error(`no open request "${requestId}" in the mailbox`);
94
+ writeJson(stageFile('replies', requestId), { requestId, reply, at: now() });
95
+ rmSync(stageFile('requests', requestId), { force: true });
96
+ rmSync(stageFile('delivered', requestId), { force: true });
97
+ },
98
+ // Removes leftovers of calls nobody came back for.
99
+ purge() {
100
+ for (const stage of STAGES) {
101
+ const dir = path.join(root, stage);
102
+ for (const name of listJson(dir)) {
103
+ const file = path.join(dir, name);
104
+ if (now() - statSync(file).mtimeMs > STALE_FILE_MS) rmSync(file, { force: true });
105
+ }
106
+ }
107
+ },
108
+
109
+ // --- caller side ---
110
+
111
+ listener(box) {
112
+ return liveListener(listenerFile(box), now);
113
+ },
114
+ // Every chat that is listening now, in any project and under any peer name.
115
+ listeners() {
116
+ const dir = path.join(root, 'listeners');
117
+ return listJson(dir).map((name) => liveListener(path.join(dir, name), now)).filter(Boolean);
118
+ },
119
+ post({ project, peer, requestId, from, hop, maxHops, message }) {
120
+ writeJson(stageFile('requests', requestId), { key: mailboxKey({ project, peer }), requestId, from, hop, maxHops, message, at: now() });
121
+ },
122
+ // The reply, or null while there is none. Reading it clears it, and a retry's repost with
123
+ // it: removed first, so the listener never finds the repost without the reply beside it.
124
+ takeReply(requestId) {
125
+ const file = stageFile('replies', requestId);
126
+ const stored = readJson(file);
127
+ if (!stored) return null;
128
+ rmSync(stageFile('requests', requestId), { force: true });
129
+ rmSync(file, { force: true });
130
+ return stored.reply;
131
+ },
132
+ // True while a listener holds the request: taken and not yet answered. Purge ends it after a day.
133
+ taken(requestId) {
134
+ return existsSync(stageFile('delivered', requestId));
135
+ },
136
+ // True when the request had not been taken yet and is now gone, so nothing was delivered.
137
+ withdraw(requestId) {
138
+ const file = stageFile('requests', requestId);
139
+ if (!existsSync(file)) return false;
140
+ rmSync(file, { force: true });
141
+ return true;
142
+ },
143
+ };
144
+ return mailbox;
145
+ }
package/src/orient.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Read-only orientation. No vendor commands, write probes, listener claims or messages.
2
+ import { accessSync, constants, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { defaultStateDir, openLedger } from './ledger.mjs';
5
+ import { openMailbox } from './mailbox.mjs';
6
+ import { DEFAULT_PEERS } from './peers.mjs';
7
+
8
+ export function stateAccess(dir) {
9
+ let existing = path.resolve(dir);
10
+ let exists = true;
11
+ for (;;) {
12
+ try {
13
+ const stat = statSync(existing);
14
+ if (!stat.isDirectory()) return { exists, writable: false, checked: existing, reason: 'not a directory' };
15
+ accessSync(existing, constants.W_OK | constants.X_OK);
16
+ return { exists, writable: true, checked: existing };
17
+ } catch (error) {
18
+ if (error.code !== 'ENOENT') return { exists, writable: false, checked: existing, reason: error.code ?? 'unknown' };
19
+ exists = false;
20
+ const parent = path.dirname(existing);
21
+ if (parent === existing) return { exists, writable: false, checked: existing, reason: 'no existing parent' };
22
+ existing = parent;
23
+ }
24
+ }
25
+ }
26
+
27
+ export function orient({ cwd = process.cwd(), from, stateDir = defaultStateDir() } = {}) {
28
+ const project = path.resolve(cwd);
29
+ const location = path.resolve(stateDir);
30
+ const ledger = openLedger(location, { readOnly: true });
31
+ const mailbox = openMailbox(location);
32
+ const routes = new Map();
33
+ for (const entry of ledger.entries()) {
34
+ if (entry.project === project && typeof entry.from === 'string' && entry.from && Object.hasOwn(DEFAULT_PEERS, entry.peer) && (!from || entry.from === from || entry.peer === from)) {
35
+ routes.set(JSON.stringify([entry.from, entry.peer]), { project, from: entry.from, peer: entry.peer });
36
+ }
37
+ }
38
+ if (from) for (const peer of Object.keys(DEFAULT_PEERS)) {
39
+ if (peer === from) continue;
40
+ routes.set(JSON.stringify([from, peer]), { project, from, peer });
41
+ if (Object.hasOwn(DEFAULT_PEERS, from)) routes.set(JSON.stringify([peer, from]), { project, from: peer, peer: from });
42
+ }
43
+ const listeners = Object.entries(DEFAULT_PEERS).filter(([, peer]) => peer.vendor === 'claude').map(([peer]) => ({
44
+ peer, registration: mailbox.listener({ project, peer }),
45
+ }));
46
+ const sortedRoutes = [...routes.values()].sort((a, b) => Number(Object.hasOwn(DEFAULT_PEERS, b.from)) - Number(Object.hasOwn(DEFAULT_PEERS, a.from)) || a.from.localeCompare(b.from) || a.peer.localeCompare(b.peer));
47
+ const vendor = DEFAULT_PEERS[from]?.vendor;
48
+ return {
49
+ project,
50
+ projectResolution: 'absolute lexical path from --cwd or the current directory; no symlink canonicalization',
51
+ state: {
52
+ location,
53
+ access: stateAccess(location),
54
+ note: 'Advisory filesystem permission check only; no write attempted. Sandboxes or ACLs may still refuse writes. Both assistants must use this same location.',
55
+ },
56
+ routes: sortedRoutes.slice(0, 20).map((route) => ({
57
+ from: route.from, peer: route.peer,
58
+ role: !from ? 'project route' : route.from === from ? 'outgoing' : 'incoming',
59
+ standing: ledger.standingThread(route),
60
+ fallback: ledger.lastThread(route),
61
+ })),
62
+ routesOmitted: Math.max(0, sortedRoutes.length - 20),
63
+ listeners,
64
+ routing: 'An unpinned Claude call may use a registered listener unless that peer is waiting for this caller; otherwise the standing thread, then the last eligible answer, then a new chat. Explicit --thread and --new bypass this default.',
65
+ listenerNote: 'Registration/heartbeat freshness only, including held listen --once claims; delivery and end-to-end reachability not tested.',
66
+ nextSteps: {
67
+ caller: `Use find --side ${vendor === 'codex' ? 'claude' : vendor === 'claude' ? 'codex' : '<codex|claude>'} --project --cwd ${JSON.stringify(project)} to locate chats, then read --side <side> --thread <id>. Inspect standing --to <peer> --from ${from ? JSON.stringify(from) : '<caller>'} --cwd ${JSON.stringify(project)} before changing a route.`,
68
+ listener: `${vendor === 'codex' ? 'Codex callers do not run listen; the receiving Claude Desktop chat does.' : vendor === 'claude' ? 'As a Claude peer, listen in the exact intended Desktop chat.' : 'Caller side unknown; only a Claude Desktop chat runs listen.'} Run listen --as <peer> --thread <this exact chat id> --once --cwd <project>, then restart after output; reply by request-id. This report does not start it.`,
69
+ owner: 'Run npm run doctor from the bridge installation for installation/sign-in checks. Only a separately authorized real call and reply can test delivery.',
70
+ },
71
+ };
72
+ }
package/src/peers.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // Peer names are the words the owner uses: "call Astra", "spin off Sol".
2
+
3
+ export const DEFAULT_PEERS = Object.freeze({
4
+ astra: Object.freeze({ vendor: 'codex', model: 'gpt-6-astra', effort: 'ultra' }),
5
+ sol: Object.freeze({ vendor: 'codex', model: 'gpt-6.1-sol', effort: 'ultra' }),
6
+ fable: Object.freeze({ vendor: 'claude', model: 'claude-fable-5-2' }),
7
+ opus: Object.freeze({ vendor: 'claude', model: 'claude-opus-5-5', effort: 'high' }),
8
+ });
9
+
10
+ export function resolvePeer(name, { model, effort, peers = DEFAULT_PEERS } = {}) {
11
+ const key = String(name ?? '').toLowerCase();
12
+ const peer = Object.hasOwn(peers, key) ? peers[key] : undefined;
13
+ if (!peer) {
14
+ throw new Error(`unknown peer "${name}"; known peers: ${Object.keys(peers).join(', ')}`);
15
+ }
16
+ const level = effort ?? peer.effort;
17
+ return { name: key, vendor: peer.vendor, model: model ?? peer.model, ...(level === undefined ? {} : { effort: level }) };
18
+ }
@@ -0,0 +1,116 @@
1
+ // Per-install consent and durable Desktop work; never store prompts, replies or target paths.
2
+ // Public API: bind/disable, enqueue, due/work, report/retry, status.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { existsSync, mkdirSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { checkStateDir, writeState } from './writable.mjs';
7
+
8
+ export const REMINDER_MS = 5 * 60_000;
9
+ export const MAX_NOTICES = 3;
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ export function sessionId(value) {
12
+ const id = String(value ?? '').replace(/^local_/, '').toLowerCase();
13
+ if (!UUID.test(id)) throw new Error('Expected a Claude Desktop session UUID');
14
+ return id;
15
+ }
16
+
17
+ const SCHEMA = `
18
+ CREATE TABLE IF NOT EXISTS coordinator (singleton INTEGER PRIMARY KEY CHECK(singleton=1), box TEXT NOT NULL, session TEXT NOT NULL);
19
+ CREATE TABLE IF NOT EXISTS jobs (target TEXT PRIMARY KEY, state TEXT NOT NULL, receipt TEXT, notified INTEGER, verified INTEGER, attempts INTEGER NOT NULL DEFAULT 0);
20
+ `;
21
+ const counts = (db) => Object.fromEntries(db.prepare('SELECT state, COUNT(*) AS n FROM jobs GROUP BY state').all().map(r => [r.state, r.n]));
22
+ const bound = (db) => db.prepare('SELECT box, session FROM coordinator').get();
23
+ const matches = (db, box, session) => {
24
+ const current = bound(db);
25
+ return current && current.box === box && current.session === session;
26
+ };
27
+
28
+ export function remoteStore(dir, { now = Date.now } = {}) {
29
+ const file = path.join(dir, 'remote-control.sqlite');
30
+ function access(fn, { create = false, write = false, absent = null } = {}) {
31
+ if (!create && !existsSync(file)) {
32
+ checkStateDir(dir);
33
+ return absent;
34
+ }
35
+ const SQLite = process.getBuiltinModule('node:sqlite')?.DatabaseSync;
36
+ if (!SQLite) throw new Error('Remote Control provisioning requires Node.js with built-in SQLite');
37
+ return writeState(() => {
38
+ if (create) mkdirSync(dir, { recursive: true });
39
+ const db = new SQLite(file);
40
+ try {
41
+ db.exec('PRAGMA busy_timeout=5000');
42
+ if (write) db.exec('BEGIN IMMEDIATE');
43
+ if (create) db.exec(SCHEMA);
44
+ const result = fn(db);
45
+ if (write) db.exec('COMMIT');
46
+ return result;
47
+ } finally { db.close(); }
48
+ });
49
+ }
50
+ return {
51
+ bind(box, session) {
52
+ session = sessionId(session);
53
+ return access(db => {
54
+ if (bound(db) && !matches(db, box, session)) throw new Error('A different coordinator is bound; disable the old binding first');
55
+ db.prepare('INSERT OR IGNORE INTO coordinator VALUES(1, ?, ?)').run(box, session);
56
+ return { enabled: true, coordinator: session };
57
+ }, { create: true, write: true });
58
+ },
59
+ disable() {
60
+ return access(db => {
61
+ db.exec("DELETE FROM coordinator; UPDATE jobs SET state='cancelled', receipt=NULL WHERE state IN ('pending','approval-required','unavailable')");
62
+ return { enabled: false };
63
+ }, { write: true, absent: { enabled: false } });
64
+ },
65
+ enqueue(target) {
66
+ target = sessionId(target);
67
+ return access(db => {
68
+ if (!bound(db)) return { state: 'not-configured' };
69
+ db.prepare("INSERT OR IGNORE INTO jobs(target, state) VALUES(?, 'pending')").run(target);
70
+ return { state: db.prepare('SELECT state FROM jobs WHERE target=?').get(target).state };
71
+ }, { write: true, absent: { state: 'not-configured' } });
72
+ },
73
+ due(box, session) {
74
+ return access(db => {
75
+ if (!matches(db, box, session)) return [];
76
+ const when = now();
77
+ db.prepare("UPDATE jobs SET state='unavailable', receipt=NULL WHERE state='pending' AND attempts>=? AND notified<=?").run(MAX_NOTICES, when - REMINDER_MS);
78
+ const rows = db.prepare("SELECT target FROM jobs WHERE state='pending' AND (notified IS NULL OR notified<=?) ORDER BY notified, rowid LIMIT 8").all(when - REMINDER_MS);
79
+ const mark = db.prepare('UPDATE jobs SET notified=?, attempts=attempts+1, receipt=COALESCE(receipt,?) WHERE target=?');
80
+ for (const { target } of rows) mark.run(when, randomUUID(), target);
81
+ return rows.map(r => r.target);
82
+ }, { write: true, absent: [] });
83
+ },
84
+ work(box, session) {
85
+ return access(db => matches(db, box, session)
86
+ ? db.prepare("SELECT target, receipt, attempts FROM jobs WHERE state='pending' AND receipt IS NOT NULL ORDER BY rowid").all() : [], { absent: [] });
87
+ },
88
+ report(target, receipt, result) {
89
+ target = sessionId(target);
90
+ if (!['active', 'approval-required', 'unavailable'].includes(result)) throw new Error('Result must be active, approval-required, or unavailable');
91
+ const recorded = access(db => {
92
+ const job = db.prepare('SELECT state, receipt FROM jobs WHERE target=?').get(target);
93
+ if (!bound(db) || !receipt || job?.receipt !== receipt || !['pending', result].includes(job.state)) {
94
+ throw new Error('No matching outstanding Desktop verification');
95
+ }
96
+ if (job.state === 'pending') db.prepare('UPDATE jobs SET state=?, verified=? WHERE target=?').run(result, result === 'active' ? now() : null, target);
97
+ return { target, state: result };
98
+ }, { write: true });
99
+ if (!recorded) throw new Error('No Remote Control configuration');
100
+ return recorded;
101
+ },
102
+ retry(target) {
103
+ target = sessionId(target);
104
+ const changed = access(db => bound(db) && db.prepare("UPDATE jobs SET state='pending', receipt=NULL, notified=NULL, attempts=0 WHERE target=? AND state IN ('approval-required','unavailable')").run(target).changes, { write: true });
105
+ if (!changed) throw new Error('No eligible blocked job; only bridge-created jobs can be retried');
106
+ return { target, state: 'pending' };
107
+ },
108
+ status(target) {
109
+ if (target) target = sessionId(target);
110
+ return access(db => ({
111
+ enabled: Boolean(bound(db)), coordinator: bound(db)?.session ?? null,
112
+ ...(target ? { target, job: db.prepare('SELECT state, attempts, verified AS verifiedAtMs FROM jobs WHERE target=?').get(target) ?? null } : { counts: counts(db) }),
113
+ }), { absent: { enabled: false, ...(target ? { target, job: null } : { counts: {} }) } });
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,64 @@
1
+ // Wire new Claude Desktop imports to an owner's explicitly bound, attended coordinator.
2
+ // No headless fallback: only the Desktop session tools can change and verify this switch.
3
+ import path from 'node:path';
4
+ import { mailboxKey } from './mailbox.mjs';
5
+ import { resolvePeer } from './peers.mjs';
6
+ import { sessionId } from './remote-control-store.mjs';
7
+
8
+ export function provisionNewChat({ outcome, vendor, optOut, store, createdForRequest }) {
9
+ if (vendor !== 'claude' || optOut || outcome.state !== 'answered' || outcome.delivery !== 'headless'
10
+ || outcome.continued !== false || !outcome.app?.opened) return;
11
+ // The ID is the session returned by this creation, never an ID in a user's prompt.
12
+ try {
13
+ if (!createdForRequest(outcome.threadId)) throw new Error('Desktop target differs from the session ID allocated by the bridge; not enrolled');
14
+ outcome.app.remoteControl = store.enqueue(outcome.threadId);
15
+ }
16
+ catch (error) { outcome.app.remoteControl = { state: 'setup-error', reason: error.message }; }
17
+ }
18
+
19
+ function liveIdentity(values, mailbox) {
20
+ const peer = resolvePeer(values.as);
21
+ if (peer.vendor !== 'claude') throw new Error('The coordinator must be a Claude Desktop peer');
22
+ const box = { peer: peer.name, project: path.resolve(values.cwd ?? process.cwd()) };
23
+ const session = sessionId(values.thread);
24
+ const live = mailbox.listener(box)?.session;
25
+ if (!live || sessionId(live) !== session) throw new Error('Start the named Desktop chat listener in this project first; its ID must match --thread');
26
+ return { key: mailboxKey(box), session };
27
+ }
28
+
29
+ export function remoteCommand(action, values, { store, mailbox }) {
30
+ if (action === 'enable' || action === 'work') {
31
+ const { key, session } = liveIdentity(values, mailbox);
32
+ if (action === 'enable') return store.bind(key, session);
33
+ return {
34
+ jobs: store.work(key, session),
35
+ instructions: 'For listed targets only, recheck work before acting. Call native get_session(local_<target>), set_remote_control(enabled=true) if needed, then get_session again. Report active only if that session shows remoteControlState on AND remoteControlActive true. If import or connection is delayed, leave pending. Report approval-required or unavailable if blocked. Never use headless or change global settings.',
36
+ report: 'nomeat remote-control report --target <target> --receipt <receipt> --result active|approval-required|unavailable',
37
+ };
38
+ }
39
+ if (action === 'disable') return store.disable();
40
+ if (action === 'status') return store.status(values.target);
41
+ if (action === 'report') return store.report(values.target, values.receipt, values.result);
42
+ if (action === 'retry') return store.retry(values.target);
43
+ throw new Error('remote-control needs enable, disable, status, work, report, or retry');
44
+ }
45
+
46
+ // The listener announces durable work, not a regular peer call. Missing Desktop/offline:
47
+ // jobs survive. Unacknowledged notices are reissued and enabling an already-on chat is safe.
48
+ export function notifyRemoteWork({ store, box, session, emit, warn }) {
49
+ if (!session) return () => {};
50
+ let lastError;
51
+ return () => {
52
+ try {
53
+ const targets = store.due(mailboxKey(box), sessionId(session));
54
+ if (targets.length) emit({
55
+ kind: 'remote-control', message: 'ASK: Enable and verify Remote Control for new bridge chats. Run the work command below, follow its Desktop instructions, then report each result. Do not use nomeat reply.',
56
+ args: ['remote-control', 'work', '--as', box.peer, '--thread', session, '--cwd', box.project],
57
+ });
58
+ lastError = undefined;
59
+ } catch (error) {
60
+ if (lastError !== error.message) warn(`Remote Control check skipped: ${error.message}`);
61
+ lastError = error.message;
62
+ }
63
+ };
64
+ }