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/LICENSE +21 -0
- package/README.md +122 -3
- package/bin/nomeat.mjs +365 -0
- package/docs/assets/not-a-meat-proxy.png +0 -0
- package/docs/usage.md +466 -0
- package/package.json +59 -6
- package/src/adapters/claude-attended.mjs +51 -0
- package/src/adapters/claude.mjs +158 -0
- package/src/adapters/codex-app.mjs +58 -0
- package/src/adapters/codex-cli.mjs +38 -0
- package/src/adapters/codex-daemon.mjs +47 -0
- package/src/adapters/codex-transport.mjs +90 -0
- package/src/adapters/codex.mjs +268 -0
- package/src/adapters/opener.mjs +29 -0
- package/src/call.mjs +193 -0
- package/src/check.mjs +46 -0
- package/src/doctor-live.mjs +123 -0
- package/src/doctor.mjs +213 -0
- package/src/history/claude.mjs +263 -0
- package/src/history/codex.mjs +164 -0
- package/src/history/compact.mjs +149 -0
- package/src/history/folder.mjs +10 -0
- package/src/history/index.mjs +35 -0
- package/src/history/match.mjs +20 -0
- package/src/history/render.mjs +23 -0
- package/src/ledger.mjs +118 -0
- package/src/listen.mjs +36 -0
- package/src/listener-lease.mjs +88 -0
- package/src/mailbox.mjs +145 -0
- package/src/orient.mjs +72 -0
- package/src/peers.mjs +18 -0
- package/src/remote-control-store.mjs +116 -0
- package/src/remote-control.mjs +64 -0
- package/src/standing.mjs +54 -0
- package/src/stats.mjs +65 -0
- package/src/usage.mjs +44 -0
- package/src/writable.mjs +49 -0
- package/templates/peer-calls.md +136 -0
- package/tools/doctor.mjs +109 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Runs a program that opens a chat in a desktop app, for the Claude and the Codex opener alike.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
// Waits for the opener to exit. `opened: true` only when it exited with code 0; another exit
|
|
5
|
+
// code or running out of time is reported, never thrown. A failure to start rejects, so that
|
|
6
|
+
// each opener words it. The opener's own output is never read. At the time limit the opener is
|
|
7
|
+
// sent a plain kill signal: on macOS that should end `script`, whose closing terminal hangs up
|
|
8
|
+
// the Claude opener under it.
|
|
9
|
+
export function runOpener([file, args], { cwd, start = spawn, timeoutMs }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = start(file, args, { cwd, detached: true, stdio: 'ignore' });
|
|
12
|
+
// At the limit the answer is given at once, without waiting for an exit, and the child is
|
|
13
|
+
// released so that one that survives the kill cannot keep the bridge running.
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
const signalled = child.kill();
|
|
16
|
+
child.unref();
|
|
17
|
+
const limit = `the opener did not exit within ${timeoutMs / 1000} seconds`;
|
|
18
|
+
resolve({ opened: false, reason: signalled ? `${limit}; a stop signal was sent` : `${limit} and could not be stopped` });
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
child.once('error', (error) => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
reject(error);
|
|
23
|
+
});
|
|
24
|
+
child.once('exit', (code, signal) => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
resolve(code === 0 ? { opened: true } : { opened: false, reason: `the opener exited with ${code === null ? `signal ${signal}` : `code ${code}`}` });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/call.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// One call from an assistant to a peer: pick the thread, bound the exchange, refuse
|
|
2
|
+
// duplicates, and record every state before and after the vendor is contacted.
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_HOPS = 4;
|
|
6
|
+
const TITLE_TEXT_LENGTH = 60;
|
|
7
|
+
|
|
8
|
+
const NOTES = Object.freeze({
|
|
9
|
+
sent: 'sent without waiting; the reply will not come back to this call',
|
|
10
|
+
duplicate: 'this request was already sent or answered; read the thread instead of sending again',
|
|
11
|
+
uncertain: 'an earlier attempt may have been delivered; inspect the thread, then resend with --force only if the message is absent',
|
|
12
|
+
attendedStanding: 'that chat answers from its own app, and the standing thread is continued without the app; nothing was sent. Send with --thread alone, or name another thread',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Deliveries where the peer's own app runs the turn with the chat's own model and level.
|
|
16
|
+
const RUNS_AS_IS = new Set(['queued', 'attended']);
|
|
17
|
+
|
|
18
|
+
// `named` lists the settings the caller gave itself, not the peer's defaults.
|
|
19
|
+
const dropped = (delivery, named) => (RUNS_AS_IS.has(delivery) && named?.length ? { notApplied: named } : {});
|
|
20
|
+
|
|
21
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
22
|
+
|
|
23
|
+
// Unique text inside the header, used to find this request's turn in a thread later.
|
|
24
|
+
export const requestMarker = (requestId) => `request ${requestId};`;
|
|
25
|
+
|
|
26
|
+
// Seen on macOS: the Codex app did not list a chat the bridge made until it re-read its list, and
|
|
27
|
+
// a question card raised there never reached the owner although the card tool answered accepted.
|
|
28
|
+
const UNLISTED_CHAT = 'The owner may not see this chat: put any question for the owner in your answer, marked for the owner, not in a question card.';
|
|
29
|
+
|
|
30
|
+
// The recipient must be able to tell a peer's message from the owner's own words. Kept to
|
|
31
|
+
// one short line: it is sent with every message. The opening words are what the calling
|
|
32
|
+
// rules tell an assistant to look for, and the request marker is what recovery searches for.
|
|
33
|
+
// `unlisted` adds one line for a chat the owner may not see.
|
|
34
|
+
export function stampMessage({ message, from, requestId, hop, maxHops, unlisted = false }) {
|
|
35
|
+
const header = `[nomeat call from ${from}; ${requestMarker(requestId)} hop ${hop}/${maxHops}; AI, not user]`;
|
|
36
|
+
return `${header}${unlisted ? `\n${UNLISTED_CHAT}` : ''}\n\n${message}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Shown in the peer's app so the owner can tell bridge threads, and their projects, apart.
|
|
40
|
+
export function threadTitle({ from, peer, project, message }) {
|
|
41
|
+
const text = message.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
42
|
+
const shown = text.length > TITLE_TEXT_LENGTH ? `${text.slice(0, TITLE_TEXT_LENGTH)}...` : text;
|
|
43
|
+
// Both separators, so a Windows path is read correctly on any platform.
|
|
44
|
+
const folder = project.split(/[\\/]/).filter(Boolean).at(-1);
|
|
45
|
+
return `${from} to ${peer} [${folder}]: ${shown}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function priorOutcome(history) {
|
|
49
|
+
const threadId = history.findLast((entry) => entry.threadId)?.threadId ?? null;
|
|
50
|
+
if (history.some((entry) => entry.state === 'answered' || entry.state === 'sent')) return { state: 'duplicate', threadId };
|
|
51
|
+
const lastAttempt = history.findLast((entry) => entry.state === 'submitting' || entry.state === 'failed');
|
|
52
|
+
if (!lastAttempt) return null;
|
|
53
|
+
// A failure known to have happened before anything was sent is safe to retry.
|
|
54
|
+
if (lastAttempt.state === 'failed' && lastAttempt.sent === false) return null;
|
|
55
|
+
return { state: 'uncertain', threadId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// How the caller steered this call away from the route's default thread, if it did.
|
|
59
|
+
const steering = (request) => (request.threadId ? 'thread' : request.newThread ? 'new' : null);
|
|
60
|
+
|
|
61
|
+
// An explicit thread wins, and a new thread starts fresh. Otherwise the standing conversation
|
|
62
|
+
// between this caller and peer in this project continues; where none has been named yet, the
|
|
63
|
+
// last answered thread that no caller steered does.
|
|
64
|
+
function chooseThread(request, ledger, route) {
|
|
65
|
+
if (request.threadId) return request.threadId;
|
|
66
|
+
if (request.newThread) return null;
|
|
67
|
+
return ledger.standingThread(route) ?? ledger.lastThread(route);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A thread becomes the standing one when the caller says so, or when the route has none and
|
|
71
|
+
// the caller did not steer this call: a side thread or an exact thread must not take over by
|
|
72
|
+
// being first or recent. A chat that answers from its app is never named: the standing
|
|
73
|
+
// thread is continued without the app.
|
|
74
|
+
function nameStanding({ request, ledger, route, result, attendedChat }) {
|
|
75
|
+
if (!result.threadId || result.delivery === 'attended' || attendedChat(result.threadId)) return;
|
|
76
|
+
const first = !steering(request) && !ledger.standingThread(route);
|
|
77
|
+
// Recorded for the route, not under the request id: it is not a step of that request.
|
|
78
|
+
if (request.makeStanding || first) ledger.append({ ...route, state: 'standing', threadId: result.threadId });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Before reporting an uncertain delivery, look in the thread: the answer may already be there.
|
|
82
|
+
async function recoverAnswer({ prior, recover, requestId }) {
|
|
83
|
+
if (prior.state !== 'uncertain' || !recover) return null;
|
|
84
|
+
return recover({ threadId: prior.threadId, marker: requestMarker(requestId), requestId });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `attendedChat` says why a chat cannot be a standing thread, or null when it can.
|
|
88
|
+
export async function callPeer(request, { ledger, adapters, recoverers = {}, attendedChat = () => null, newId = randomUUID }) {
|
|
89
|
+
const { peer, message, from, project, effort, write = false, force = false } = request;
|
|
90
|
+
const hop = request.hop ?? 1;
|
|
91
|
+
const maxHops = request.maxHops ?? DEFAULT_MAX_HOPS;
|
|
92
|
+
const requestId = request.requestId ?? newId();
|
|
93
|
+
// A peer without a model uses the vendor's default, and the result then has no model field.
|
|
94
|
+
const header = (model) => ({ requestId, peer: peer.name, vendor: peer.vendor, ...(model == null ? {} : { model }) });
|
|
95
|
+
let base = header(peer.model);
|
|
96
|
+
const route = { project, from, peer: peer.name };
|
|
97
|
+
|
|
98
|
+
if (hop > maxHops) {
|
|
99
|
+
const note = `hop ${hop} exceeds the limit of ${maxHops}; put the open question to the owner instead of calling again`;
|
|
100
|
+
return { ...base, state: 'refused', threadId: request.threadId ?? null, note };
|
|
101
|
+
}
|
|
102
|
+
// A fresh request sends nothing before the adapter is called: a failure up to there, such as
|
|
103
|
+
// a state folder that cannot be read or written, is marked not sent. Once an earlier attempt
|
|
104
|
+
// is found, it may have been delivered, so a failure while recovering it is not marked.
|
|
105
|
+
let adapter, threadId, threadAccess, prior, model = peer.model, level = effort;
|
|
106
|
+
try {
|
|
107
|
+
if (request.makeStanding && request.threadId && attendedChat(request.threadId)) {
|
|
108
|
+
return { ...base, state: 'refused', threadId: request.threadId, note: NOTES.attendedStanding };
|
|
109
|
+
}
|
|
110
|
+
const history = ledger.history(requestId);
|
|
111
|
+
prior = force ? null : priorOutcome(history);
|
|
112
|
+
if (prior) {
|
|
113
|
+
// Report the model the earlier attempt sent, which a call into an existing thread may not have.
|
|
114
|
+
base = header(history.findLast((entry) => entry.state === 'submitting')?.model);
|
|
115
|
+
const found = await recoverAnswer({ prior, recover: recoverers[peer.vendor], requestId });
|
|
116
|
+
if (!found) return { ...base, ...prior, note: NOTES[prior.state] };
|
|
117
|
+
ledger.append({ requestId, ...route, state: 'answered', threadId: prior.threadId, recovered: true });
|
|
118
|
+
// A recoverer states the delivery only when it knows it; Codex's cannot tell direct from queued.
|
|
119
|
+
return { ...base, state: 'answered', threadId: prior.threadId, continued: true, recovered: true, ...dropped(found.delivery, request.named), reply: found.reply };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
adapter = adapters[peer.vendor];
|
|
123
|
+
if (!adapter) throw new Error(`no adapter for vendor "${peer.vendor}"`);
|
|
124
|
+
threadId = chooseThread(request, ledger, route);
|
|
125
|
+
// An existing Codex thread keeps its own model and level unless the caller named one; the
|
|
126
|
+
// peer's defaults are for a new thread. A Claude session is still given the peer's model.
|
|
127
|
+
if (peer.vendor === 'codex' && threadId) {
|
|
128
|
+
const named = request.named ?? [];
|
|
129
|
+
if (!named.includes('model')) model = undefined;
|
|
130
|
+
if (!named.includes('effort')) level = undefined;
|
|
131
|
+
base = header(model);
|
|
132
|
+
}
|
|
133
|
+
const steered = steering(request);
|
|
134
|
+
threadAccess = ledger.threadAccess(threadId);
|
|
135
|
+
// Written last before contacting the vendor, so that a crash mid-send reads as "maybe
|
|
136
|
+
// delivered" and a failure before it leaves no entry that would block a retry.
|
|
137
|
+
ledger.append({ ...base, ...route, state: 'submitting', hop, threadId, ...(steered ? { steered } : {}), messageSha256: sha256(message), messageChars: message.length });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!prior) Object.assign(error, { notSent: true, message: `${error.message}; nothing was sent` });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// A Codex chat the bridge makes: in this call, or in an earlier one, whose access only the
|
|
145
|
+
// creating call records. Limit: a creating call whose answer was never recorded is missed.
|
|
146
|
+
const unlisted = peer.vendor === 'codex' && (!threadId || Boolean(threadAccess));
|
|
147
|
+
const result = await adapter({
|
|
148
|
+
threadId: threadId ?? undefined,
|
|
149
|
+
message: stampMessage({ message, from, requestId, hop, maxHops, unlisted }),
|
|
150
|
+
marker: requestMarker(requestId),
|
|
151
|
+
title: threadTitle({ from, peer: peer.name, project, message }),
|
|
152
|
+
model,
|
|
153
|
+
effort: level,
|
|
154
|
+
write,
|
|
155
|
+
threadAccess,
|
|
156
|
+
cwd: project,
|
|
157
|
+
onThread: (id) => ledger.append({ requestId, state: 'thread', threadId: id }),
|
|
158
|
+
// For an adapter that can choose between destinations.
|
|
159
|
+
requestId,
|
|
160
|
+
from,
|
|
161
|
+
peer: peer.name,
|
|
162
|
+
hop,
|
|
163
|
+
maxHops,
|
|
164
|
+
pinned: Boolean(request.threadId || request.newThread),
|
|
165
|
+
noWait: Boolean(request.noWait),
|
|
166
|
+
});
|
|
167
|
+
const delivery = result.delivery ? { delivery: result.delivery } : {};
|
|
168
|
+
// A delivery that went through but with something the caller should know.
|
|
169
|
+
const warning = result.warning ? { warning: result.warning } : {};
|
|
170
|
+
// A Codex turn that sets no access runs with the thread's own, so the ledger says what that is.
|
|
171
|
+
const access = result.access ?? (peer.vendor === 'codex' ? ledger.threadAccess(result.threadId) : null);
|
|
172
|
+
const applies = result.effort ? { effort: result.effort } : {};
|
|
173
|
+
const notApplied = dropped(result.delivery, request.named);
|
|
174
|
+
const applied = { ...(access ? { access } : {}), ...applies };
|
|
175
|
+
// Only the call that made a thread records its access: a later resume with write may not stick.
|
|
176
|
+
const recorded = threadId ? applies : applied;
|
|
177
|
+
nameStanding({ request, ledger, route, result, attendedChat });
|
|
178
|
+
if (request.noWait) {
|
|
179
|
+
ledger.append({ requestId, ...route, state: 'sent', threadId: result.threadId, ...delivery, ...recorded });
|
|
180
|
+
return { ...base, state: 'sent', threadId: result.threadId, continued: Boolean(threadId), ...delivery, ...applied, ...notApplied, ...warning, note: NOTES.sent };
|
|
181
|
+
}
|
|
182
|
+
// Sizes and the vendor's token figures, for measuring what calls cost. Never the text.
|
|
183
|
+
const cost = { replyChars: result.reply.length, ...(result.usage ? { usage: result.usage } : {}) };
|
|
184
|
+
ledger.append({ requestId, ...route, state: 'answered', threadId: result.threadId, ...delivery, ...recorded, ...cost });
|
|
185
|
+
return { ...base, state: 'answered', threadId: result.threadId, continued: result.continued ?? Boolean(threadId), ...delivery, ...applied, ...notApplied, ...warning, reply: result.reply };
|
|
186
|
+
} catch (error) {
|
|
187
|
+
ledger.append({ requestId, state: 'failed', sent: !error.notSent, error: error.message });
|
|
188
|
+
// An adapter that refuses an attempt says why. Recorded as possibly sent, since an earlier
|
|
189
|
+
// attempt was, so a later retry looks for the answer first.
|
|
190
|
+
if (error.refused) return { ...base, state: 'refused', threadId: request.threadId ?? null, note: error.message };
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Following up on a message that was sent without waiting: has the peer started on it, is it
|
|
2
|
+
// still working, or did it finish, and with what words. The sender asks; nothing is resent.
|
|
3
|
+
import { requestMarker } from './call.mjs';
|
|
4
|
+
|
|
5
|
+
const NOTES = Object.freeze({
|
|
6
|
+
returned: 'the reply came back to the call that sent it',
|
|
7
|
+
uncheckable: 'this kind of delivery cannot be checked from outside; read the thread instead',
|
|
8
|
+
// Only what is seen: the cause of a message not starting is not known.
|
|
9
|
+
waiting: 'the peer has not started on it; cause unknown',
|
|
10
|
+
idle: 'the chat is idle and has not picked the message up; open the chat in the Codex app, or send it again as a new call that waits (the queued copy may then run as well)',
|
|
11
|
+
running: 'the peer is working on it now',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Under a minute after sending, a message not started yet is no sign of a problem.
|
|
15
|
+
const RECENT_S = 60;
|
|
16
|
+
|
|
17
|
+
function noteFor(found, secondsAgo) {
|
|
18
|
+
if (found.state !== 'waiting') return NOTES[found.state];
|
|
19
|
+
if (secondsAgo < RECENT_S) return `sent ${secondsAgo} seconds ago; the peer has not started on it yet`;
|
|
20
|
+
return found.idle ? NOTES.idle : NOTES.waiting;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function checkRequest(requestId, { ledger, checkers = {}, now = Date.now }) {
|
|
24
|
+
const history = ledger.history(requestId);
|
|
25
|
+
const sent = history.find((entry) => entry.state === 'submitting');
|
|
26
|
+
if (!sent) return { requestId, state: 'unknown', ...ledger.notFound('no such request') };
|
|
27
|
+
const threadId = history.findLast((entry) => entry.threadId)?.threadId ?? null;
|
|
28
|
+
// The latest handover, so that a failed attempt or a slow queue before it is not counted as
|
|
29
|
+
// time the message has waited; without one, the latest attempt.
|
|
30
|
+
const handedOver = history.findLast((entry) => entry.state === 'sent') ?? history.findLast((entry) => entry.state === 'submitting');
|
|
31
|
+
const base = { requestId, peer: sent.peer, threadId, sentAt: handedOver.at };
|
|
32
|
+
if (history.some((entry) => entry.state === 'answered')) return { ...base, state: 'answered', note: NOTES.returned };
|
|
33
|
+
const check = checkers[sent.vendor];
|
|
34
|
+
if (!check || !threadId) return { ...base, state: 'sent', note: NOTES.uncheckable };
|
|
35
|
+
const found = await check({ threadId, marker: requestMarker(requestId) });
|
|
36
|
+
const note = noteFor(found, Math.max(0, Math.floor((now() - Date.parse(handedOver.at)) / 1000)));
|
|
37
|
+
return { ...base, ...found, ...(note ? { note } : {}) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The requests of one project that were sent without waiting, newest last.
|
|
41
|
+
export function sentWithoutWaiting(entries, { project, since }) {
|
|
42
|
+
const starts = new Map(entries.filter((entry) => entry.state === 'submitting').map((entry) => [entry.requestId, entry]));
|
|
43
|
+
return entries
|
|
44
|
+
.filter((entry) => entry.state === 'sent' && starts.get(entry.requestId)?.project === project && (!since || entry.at >= since))
|
|
45
|
+
.map((entry) => entry.requestId);
|
|
46
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `doctor --live`: one real, read-only, one-word call per side through the bridge's own call
|
|
2
|
+
// path, each in a new thread, from a caller name that marks it as a check. Shows that the
|
|
3
|
+
// command line is found, that sign-in and the model work, and what opening in the app said.
|
|
4
|
+
// Tests inject `run`; only the command line makes real calls.
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const BIN = fileURLToPath(new URL('../bin/nomeat.mjs', import.meta.url));
|
|
10
|
+
export const LIVE_CHECK_CALLER = 'doctor-check';
|
|
11
|
+
const MESSAGE = 'This is a check of the bridge, not a task. Reply with the single word: ok';
|
|
12
|
+
// One peer per side, with its default model and thinking level, since those are what calls use.
|
|
13
|
+
// fable is the peer whose model needs a recent Claude CLI.
|
|
14
|
+
export const LIVE_PEERS = Object.freeze([
|
|
15
|
+
{ side: 'codex', peer: 'astra', cli: 'codex-cli', auth: 'codex-auth' },
|
|
16
|
+
{ side: 'claude', peer: 'fable', cli: 'claude-cli', auth: 'claude-auth' },
|
|
17
|
+
]);
|
|
18
|
+
// One deadline per side, longer than the adapters' own ten-minute limit so they report first.
|
|
19
|
+
const DEADLINE_MS = 11 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
// Fixed wording only: vendor output and notes can carry an account identity.
|
|
22
|
+
const DETAIL = Object.freeze({
|
|
23
|
+
notRun: 'not run: the command line or the subscription sign-in was not found',
|
|
24
|
+
notOpened: 'answered, but the chat opener did not start',
|
|
25
|
+
deadline: 'no answer before the deadline; the process tree was ended; delivery uncertain, not retried',
|
|
26
|
+
cleanupFailed: 'no answer before the deadline; ending its processes did not succeed, some may still run; delivery uncertain, not retried',
|
|
27
|
+
uncertain: 'uncertain: the request may have been delivered; not retried',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Ends a process and every process it started, except those that left its tree or group on
|
|
31
|
+
// purpose, such as the detached chat opener. Windows: taskkill walks the tree by parent id, so
|
|
32
|
+
// it runs while the wrapper is still alive. Elsewhere: the wrapper leads its own process group.
|
|
33
|
+
// Bounded, and returns whether it succeeded; a group already gone counts as ended.
|
|
34
|
+
const CLEANUP_TIMEOUT_MS = 10000;
|
|
35
|
+
function endTree(pid) {
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: CLEANUP_TIMEOUT_MS }).status === 0;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
process.kill(-pid, 'SIGKILL');
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return error.code === 'ESRCH';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Runs Node with `args` and resolves with its exit status and stdout. At the deadline the whole
|
|
48
|
+
// tree is ended and the promise settles at once, whether or not the process ever closes, with
|
|
49
|
+
// `timedOut` and `treeEnded`. Stderr is drained and dropped.
|
|
50
|
+
export function runWithDeadline(args, { deadlineMs = DEADLINE_MS, endTree: end = endTree } = {}) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: process.platform !== 'win32' });
|
|
53
|
+
let stdout = '';
|
|
54
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
55
|
+
child.stderr.resume();
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
const treeEnded = end(child.pid);
|
|
58
|
+
// A process that outlived its cleanup must not keep this one alive.
|
|
59
|
+
child.stdout.destroy();
|
|
60
|
+
child.stderr.destroy();
|
|
61
|
+
child.unref();
|
|
62
|
+
resolve({ status: null, stdout, timedOut: true, treeEnded });
|
|
63
|
+
}, deadlineMs);
|
|
64
|
+
const done = (status) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
resolve({ status, stdout, timedOut: false });
|
|
67
|
+
};
|
|
68
|
+
child.once('error', () => done(null));
|
|
69
|
+
child.once('close', done);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `--new` keeps the check away from standing threads and listening chats; no Remote Control
|
|
74
|
+
// work is queued for a check chat. The request id is given, so the ledger never sends it twice.
|
|
75
|
+
export function callThroughBridge(peer, requestId) {
|
|
76
|
+
return runWithDeadline([BIN, 'call', '--to', peer, '--from', LIVE_CHECK_CALLER, '--new', '--no-remote-control', '--request-id', requestId, '--message', MESSAGE]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseOutcome(stdout) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(stdout);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function describe(r, outcome, answered, ok) {
|
|
88
|
+
if (r.timedOut) return r.treeEnded ? DETAIL.deadline : DETAIL.cleanupFailed;
|
|
89
|
+
if (outcome?.state === 'uncertain') return DETAIL.uncertain;
|
|
90
|
+
if (answered) return ok ? '' : DETAIL.notOpened;
|
|
91
|
+
// Only a known state name is repeated, never free text from the result.
|
|
92
|
+
const state = ['sent', 'duplicate', 'refused'].includes(outcome?.state) ? `state ${outcome.state}, ` : '';
|
|
93
|
+
return `not answered: ${state}exit code ${r.status}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Called once, never retried. The reply itself is not reported: the check says whether there was one.
|
|
97
|
+
async function checkSide({ side, peer }, { cliFound, ready }, run) {
|
|
98
|
+
if (!ready) return { side, peer, cliFound, answered: false, appOpened: null, ok: false, detail: DETAIL.notRun };
|
|
99
|
+
const r = await run(peer, randomUUID());
|
|
100
|
+
const outcome = r.timedOut ? null : parseOutcome(r.stdout);
|
|
101
|
+
const answered = outcome?.state === 'answered' && Boolean(outcome.reply?.trim());
|
|
102
|
+
// Only the Claude opener counts. A new Codex chat is opened by its link too, but that result is
|
|
103
|
+
// not checked here: its `opened` shows only that the system took the link.
|
|
104
|
+
// null where the result had nothing to say: a Codex side, or a call that did not answer.
|
|
105
|
+
const appOpened = side === 'claude' && typeof outcome?.app?.opened === 'boolean' ? outcome.app.opened : null;
|
|
106
|
+
const ok = answered && (side !== 'claude' || appOpened === true);
|
|
107
|
+
return { side, peer, cliFound, answered, appOpened, ok, detail: describe(r, outcome, answered, ok) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `checks` are those of inspectPrerequisites. A side is called only when its command line and
|
|
111
|
+
// its sign-in are both 'found': a warning, such as paid API credentials, is not enough.
|
|
112
|
+
export async function runLiveChecks({ checks, run = callThroughBridge }) {
|
|
113
|
+
const status = (id) => checks.find((c) => c.id === id)?.status;
|
|
114
|
+
const sides = [];
|
|
115
|
+
for (const target of LIVE_PEERS) {
|
|
116
|
+
const cliFound = ['found', 'warning'].includes(status(target.cli));
|
|
117
|
+
const ready = status(target.cli) === 'found' && status(target.auth) === 'found';
|
|
118
|
+
sides.push(await checkSide(target, { cliFound, ready }, run));
|
|
119
|
+
}
|
|
120
|
+
return { ok: sides.every((s) => s.ok), sides };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const liveExitCode = (live) => (live.ok ? 0 : 1);
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Read-only check of what the bridge depends on. Runs nothing on import. Reports versions,
|
|
2
|
+
// counts and signed in or not only: no paths, user names, account details, message content or
|
|
3
|
+
// raw command output, so the result is safe to commit.
|
|
4
|
+
// Sections: command runner / probes / inspectPrerequisites / host.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { defaultStateDir } from './ledger.mjs';
|
|
10
|
+
import { canWrite } from './writable.mjs';
|
|
11
|
+
|
|
12
|
+
const isWin = process.platform === 'win32';
|
|
13
|
+
|
|
14
|
+
// A limit of one model, not of the bridge. On the first macOS run, Claude CLI 2.1.283 had calls
|
|
15
|
+
// to this model refused with "API Error 400"; the vendor's message named 2.1.290 as the minimum,
|
|
16
|
+
// and 2.1.322 was seen working (docs/evidence/2026-11-11-macos-baseline.md).
|
|
17
|
+
export const CLAUDE_CLI_MINIMUM_FOR_MODEL = Object.freeze({ model: 'claude-fable-5-2', version: '2.1.290' });
|
|
18
|
+
|
|
19
|
+
// --- command runner ---
|
|
20
|
+
|
|
21
|
+
// The default `capture`: runs a command and returns whether it succeeded and its output.
|
|
22
|
+
export function runCommand(file, args = [], { shell = false, encoding = 'utf8' } = {}) {
|
|
23
|
+
const options = { encoding, timeout: 20000, windowsHide: true };
|
|
24
|
+
const r = shell ? spawnSync(file, { ...options, shell: true }) : spawnSync(file, args, options);
|
|
25
|
+
if (r.error || r.status === null) return { ok: false, out: '' };
|
|
26
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ''}\n${r.stderr ?? ''}`.trim() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const firstVersion = (text) => text.match(/\d+\.\d+(?:\.\d+)*/)?.[0] ?? null;
|
|
30
|
+
const found = (version = null, note = '') => ({ status: 'found', version, note });
|
|
31
|
+
const missing = (note = '') => ({ status: 'missing', version: null, note });
|
|
32
|
+
const unknown = (note) => ({ status: 'unknown', version: null, note });
|
|
33
|
+
const NOT_CHECKED_HERE = unknown('not checked on this platform');
|
|
34
|
+
|
|
35
|
+
export function compareVersions(a, b) {
|
|
36
|
+
const x = a.split('.').map(Number);
|
|
37
|
+
const y = b.split('.').map(Number);
|
|
38
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
39
|
+
const diff = (x[i] ?? 0) - (y[i] ?? 0);
|
|
40
|
+
if (diff !== 0) return diff;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- probes ---
|
|
46
|
+
// `shell` runs a constant command string: npm-installed CLIs are .cmd shims on Windows and need one.
|
|
47
|
+
|
|
48
|
+
function cliVersion(shell, command) {
|
|
49
|
+
const r = shell(command);
|
|
50
|
+
const version = r.ok ? firstVersion(r.out) : null;
|
|
51
|
+
return version ? found(version) : missing();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cliSupports(shell, command, pattern, capability) {
|
|
55
|
+
const r = shell(command);
|
|
56
|
+
if (!r.ok) return missing(`${capability}: command failed`);
|
|
57
|
+
return pattern.test(r.out) ? found(null, capability) : missing(`${capability}: not offered by this version`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The Node version against `engines.node` in package.json. Limit: only a lower bound written as
|
|
61
|
+
// ">=x.y[.z]" is understood; any other range reads as no minimum known.
|
|
62
|
+
const NODE_RANGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).engines?.node ?? '';
|
|
63
|
+
export function nodeVersionCheck(version = process.versions.node) {
|
|
64
|
+
const minimum = NODE_RANGE.match(/^\s*>=\s*(\d+(?:\.\d+)*)\s*$/)?.[1];
|
|
65
|
+
if (!minimum) return unknown(`found ${version}; the minimum in package.json could not be read`);
|
|
66
|
+
if (compareVersions(version, minimum) >= 0) return found(version);
|
|
67
|
+
return { status: 'missing', version, note: `older than ${minimum}, the minimum this package declares` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function claudeCliVersion(shell) {
|
|
71
|
+
const result = cliVersion(shell, 'claude --version');
|
|
72
|
+
const { model, version } = CLAUDE_CLI_MINIMUM_FOR_MODEL;
|
|
73
|
+
if (result.status !== 'found' || compareVersions(result.version, version) >= 0) return result;
|
|
74
|
+
return { ...result, status: 'warning', note: `older than ${version}, the minimum the vendor named for the model ${model}; calls to that model fail until the CLI is updated` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The vendors' own sign-in status commands, reduced to a fixed note. Nothing of their output
|
|
78
|
+
// leaves these functions. 'found' means signed in with the subscription; an API key or a third
|
|
79
|
+
// party is a warning, since calls would then use paid API credentials.
|
|
80
|
+
const SUBSCRIPTION = found(null, 'signed in with the subscription');
|
|
81
|
+
const PAID_API = { status: 'warning', version: null, note: 'signed in with an API key or a third party: paid API credentials would be used' };
|
|
82
|
+
const SIGNED_OUT = missing('not signed in');
|
|
83
|
+
const UNREAD = unknown('sign-in status could not be read');
|
|
84
|
+
|
|
85
|
+
// Seen: "Logged in using ChatGPT", "Logged in using an API key - <masked key>", "Not logged in".
|
|
86
|
+
function codexSignIn(shell) {
|
|
87
|
+
const r = shell('codex login status');
|
|
88
|
+
if (/not logged in/i.test(r.out)) return SIGNED_OUT;
|
|
89
|
+
if (!r.ok) return UNREAD;
|
|
90
|
+
if (/logged in using chatgpt/i.test(r.out)) return SUBSCRIPTION;
|
|
91
|
+
return /logged in using an api key/i.test(r.out) ? PAID_API : UNREAD;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Seen in `--json`: authMethod 'claude.ai' with apiProvider 'firstParty' for a subscription,
|
|
95
|
+
// authMethod 'api_key' for an API key; another apiProvider is a third party.
|
|
96
|
+
function claudeSignIn(shell) {
|
|
97
|
+
let status;
|
|
98
|
+
try {
|
|
99
|
+
status = JSON.parse(shell('claude auth status --json').out.match(/\{[\s\S]*\}/)?.[0] ?? '');
|
|
100
|
+
} catch {
|
|
101
|
+
return UNREAD;
|
|
102
|
+
}
|
|
103
|
+
if (status?.loggedIn === false) return SIGNED_OUT;
|
|
104
|
+
if (status?.loggedIn !== true) return UNREAD;
|
|
105
|
+
if (status.authMethod === 'claude.ai' && status.apiProvider === 'firstParty') return SUBSCRIPTION;
|
|
106
|
+
return status.authMethod === 'api_key' || (status.apiProvider && status.apiProvider !== 'firstParty') ? PAID_API : UNREAD;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Chromium browsers keep one folder per installed version next to the executable.
|
|
110
|
+
function installedVersion(...relative) {
|
|
111
|
+
if (!isWin) return NOT_CHECKED_HERE;
|
|
112
|
+
const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean);
|
|
113
|
+
for (const root of roots) {
|
|
114
|
+
const dir = path.join(root, ...relative);
|
|
115
|
+
if (!existsSync(dir)) continue;
|
|
116
|
+
const versions = readdirSync(dir).filter((name) => /^\d+(\.\d+){2,3}$/.test(name)).sort(compareVersions);
|
|
117
|
+
if (versions.length > 0) return found(versions.at(-1));
|
|
118
|
+
}
|
|
119
|
+
return missing();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function queryStoreApps(capture) {
|
|
123
|
+
const script = "Get-AppxPackage | Where-Object { $_.Name -match 'Claude|OpenAI|ChatGPT' } | ForEach-Object { $_.Name + ' ' + $_.Version }";
|
|
124
|
+
const r = capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
125
|
+
if (!r.ok) return null;
|
|
126
|
+
return r.out.split(/\r?\n/).map((line) => line.trim().split(' ')).filter((parts) => parts.length === 2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function storeApp(apps, pattern) {
|
|
130
|
+
if (!isWin) return NOT_CHECKED_HERE;
|
|
131
|
+
if (!apps) return unknown('could not query installed Store packages');
|
|
132
|
+
const hit = apps.find(([name]) => pattern.test(name));
|
|
133
|
+
return hit ? found(hit[1], `Store package ${hit[0]}`) : missing('no Store package; other install types are not detected');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function wslDistros(capture) {
|
|
137
|
+
if (!isWin) return NOT_CHECKED_HERE;
|
|
138
|
+
const r = capture('wsl.exe', ['-l', '-q'], { encoding: 'utf16le' });
|
|
139
|
+
const names = r.out.split(/\r?\n/).map((line) => line.replace(/\0/g, '').trim()).filter(Boolean);
|
|
140
|
+
return r.ok && names.length > 0 ? found(null, `${names.length} distro(s)`) : missing();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sessionStore(dir, isSessionFile) {
|
|
144
|
+
if (!existsSync(dir)) return missing();
|
|
145
|
+
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
146
|
+
const count = entries.filter((entry) => entry.isFile() && isSessionFile(entry.name)).length;
|
|
147
|
+
return found(null, `${count} session files`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The folder is named by its variable, never by its path, which can hold a user name.
|
|
151
|
+
function stateFolder() {
|
|
152
|
+
if (canWrite(defaultStateDir())) return found();
|
|
153
|
+
return missing('cannot be created or written; set NOMEAT_STATE_DIR to a folder this process can write');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function githubCli(shell) {
|
|
157
|
+
const version = cliVersion(shell, 'gh --version');
|
|
158
|
+
if (version.status !== 'found') return version;
|
|
159
|
+
return { ...version, note: shell('gh auth status').ok ? 'signed in' : 'not signed in' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- inspectPrerequisites ---
|
|
163
|
+
|
|
164
|
+
// Synchronous. `ok` is false when a required check is missing. `capture(file, args, options)`
|
|
165
|
+
// returns `{ ok, out }`; tests inject one.
|
|
166
|
+
export function inspectPrerequisites({ capture = runCommand } = {}) {
|
|
167
|
+
const shell = (command) => capture(command, [], { shell: true });
|
|
168
|
+
const apps = isWin ? queryStoreApps(capture) : null;
|
|
169
|
+
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
170
|
+
const claudeHome = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
|
|
171
|
+
const check = (id, required, result) => ({ id, required, ...result });
|
|
172
|
+
const checks = [
|
|
173
|
+
check('codex-cli', true, cliVersion(shell, 'codex --version')),
|
|
174
|
+
check('codex-auth', true, codexSignIn(shell)),
|
|
175
|
+
check('codex-queue-thread', false, cliSupports(shell, 'codex queue --help', /--thread/, '`codex queue --thread`')),
|
|
176
|
+
check('codex-exec-resume', false, cliSupports(shell, 'codex exec resume --help', /SESSION_ID/, '`codex exec resume`')),
|
|
177
|
+
check('codex-sessions', false, sessionStore(path.join(codexHome, 'sessions'), (name) => /\.jsonl(\.zst)?$/.test(name))),
|
|
178
|
+
check('claude-cli', true, claudeCliVersion(shell)),
|
|
179
|
+
check('claude-auth', true, claudeSignIn(shell)),
|
|
180
|
+
check('claude-resume', false, cliSupports(shell, 'claude --help', /--resume/, '`claude -p --resume`')),
|
|
181
|
+
check('claude-sessions', false, sessionStore(path.join(claudeHome, 'projects'), (name) => name.endsWith('.jsonl'))),
|
|
182
|
+
check('codex-app', false, storeApp(apps, /codex/i)),
|
|
183
|
+
check('claude-app', false, storeApp(apps, /^claude$/i)),
|
|
184
|
+
check('chatgpt-app', false, storeApp(apps, /chatgpt/i)),
|
|
185
|
+
check('chrome', false, installedVersion('Google', 'Chrome', 'Application')),
|
|
186
|
+
check('edge', false, installedVersion('Microsoft', 'Edge', 'Application')),
|
|
187
|
+
check('node', true, nodeVersionCheck()),
|
|
188
|
+
check('state-folder', true, stateFolder()),
|
|
189
|
+
check('git', false, cliVersion(shell, 'git --version')),
|
|
190
|
+
check('github-cli', false, githubCli(shell)),
|
|
191
|
+
check('tailscale', false, cliVersion(shell, 'tailscale version')),
|
|
192
|
+
check('wsl', false, wslDistros(capture)),
|
|
193
|
+
];
|
|
194
|
+
return { ok: !checks.some((c) => c.required && c.status === 'missing'), checks };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- host ---
|
|
198
|
+
|
|
199
|
+
function readOsRelease() {
|
|
200
|
+
try {
|
|
201
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The host line of the report. Where /etc/os-release exists, as on Linux, the distribution and
|
|
208
|
+
// its version; elsewhere the system's own description, which on Linux is only the kernel build.
|
|
209
|
+
export function describeHost(osRelease = readOsRelease()) {
|
|
210
|
+
const field = (key) => osRelease?.match(new RegExp(`^${key}=["']?(.*?)["']?\s*$`, 'm'))?.[1];
|
|
211
|
+
const distribution = field('PRETTY_NAME') || [field('NAME'), field('VERSION_ID')].filter(Boolean).join(' ');
|
|
212
|
+
return `${distribution || os.version()} (${os.release()}), ${os.arch()}`;
|
|
213
|
+
}
|