kojee-mcp 0.7.0 → 0.7.2
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/dist/chunk-5DHIUN73.js +25 -0
- package/dist/{chunk-OB5T6P24.js → chunk-E35VWFZV.js} +1 -1
- package/dist/{chunk-GVWYW7MY.js → chunk-EDHG4375.js} +241 -58
- package/dist/{chunk-FSKGQ6GT.js → chunk-HI42GBQ3.js} +5 -0
- package/dist/chunk-IZN7IZPW.js +132 -0
- package/dist/{chunk-5H75AEZ2.js → chunk-R5GC2GRD.js} +62 -11
- package/dist/{chunk-OBIJ6WQP.js → chunk-RVLUZLXD.js} +63 -22
- package/dist/{chunk-QJFMU4QC.js → chunk-TMCNB4JH.js} +1 -1
- package/dist/{chunk-QEJUNP3X.js → chunk-XFGGMDZ4.js} +6 -5
- package/dist/chunk-XLRF5ATG.js +103 -0
- package/dist/{chunk-SGRVG4HW.js → chunk-ZUIYFRO5.js} +62 -30
- package/dist/cli.js +24 -14
- package/dist/codex-prompt-submit-hook-FOBPGZHJ.js +39 -0
- package/dist/codex-stop-hook-PNWYNQKM.js +136 -0
- package/dist/{connect-handler-HIRM624N.js → connect-handler-2JFIMQY6.js} +15 -7
- package/dist/{doctor-PLKLHTJM.js → doctor-HXMCO5PR.js} +2 -2
- package/dist/doctor-codex-ZSALZPH3.js +370 -0
- package/dist/{event-stream-KRYWEYWO.js → event-stream-WPN3EN7C.js} +5 -1
- package/dist/index.js +6 -5
- package/dist/{install-V7LSQCYZ.js → install-GGI6GU6C.js} +1 -1
- package/dist/lib.d.ts +40 -15
- package/dist/lib.js +7 -7
- package/dist/pending-state-6TVRR63P.js +134 -0
- package/dist/{registry-VRFHKOPP.js → registry-42Y45L6I.js} +114 -47
- package/dist/{server-YTHOMA4H.js → server-JWIH7OFA.js} +5 -2
- package/dist/{setup-handler-UHVKSX7E.js → setup-handler-GBPXDETR.js} +8 -7
- package/dist/{stop-hook-YI4KRKF2.js → stop-hook-PWTAP227.js} +2 -2
- package/dist/{tail-stream-NBGHHBS4.js → tail-stream-N43D53RC.js} +99 -19
- package/package.json +3 -2
- package/skills/using-tandems/SKILL.md +52 -0
- package/dist/chunk-WIU5WGDF.js +0 -10
- package/dist/codex-stop-hook-EOMQD3J4.js +0 -69
- package/dist/doctor-codex-HARAAH4Y.js +0 -154
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultCodexConfigPath,
|
|
3
|
+
defaultCodexHooksPath,
|
|
4
|
+
isPlaceholderWebhookUrl
|
|
5
|
+
} from "./chunk-RVLUZLXD.js";
|
|
6
|
+
import "./chunk-TMCNB4JH.js";
|
|
7
|
+
import "./chunk-D6JKFJ6A.js";
|
|
8
|
+
import {
|
|
9
|
+
CODEX_STATUS_STALE_MS,
|
|
10
|
+
readAllCodexStatusRaw,
|
|
11
|
+
readCodexStatus
|
|
12
|
+
} from "./chunk-XLRF5ATG.js";
|
|
13
|
+
import "./chunk-SQL56SEB.js";
|
|
14
|
+
import {
|
|
15
|
+
resolveWebhookConfig
|
|
16
|
+
} from "./chunk-V5VZPYMZ.js";
|
|
17
|
+
import "./chunk-U5HHHRXA.js";
|
|
18
|
+
import {
|
|
19
|
+
VERSION
|
|
20
|
+
} from "./chunk-5DHIUN73.js";
|
|
21
|
+
import {
|
|
22
|
+
CODEX_LISTEN_CAP_MS
|
|
23
|
+
} from "./chunk-XFGGMDZ4.js";
|
|
24
|
+
|
|
25
|
+
// src/doctor-codex.ts
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
|
|
28
|
+
// src/hooks/codex-trust-hash.ts
|
|
29
|
+
import crypto from "crypto";
|
|
30
|
+
var EVENT_LABELS = {
|
|
31
|
+
Stop: "stop",
|
|
32
|
+
UserPromptSubmit: "user_prompt_submit"
|
|
33
|
+
};
|
|
34
|
+
function canonicalize(value) {
|
|
35
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
36
|
+
if (value !== null && typeof value === "object") {
|
|
37
|
+
const src = value;
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const key of Object.keys(src).sort()) out[key] = canonicalize(src[key]);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function codexHookTrustHash(identity) {
|
|
45
|
+
const timeout = Math.max(1, identity.timeout ?? 600);
|
|
46
|
+
const handler = {
|
|
47
|
+
type: "command",
|
|
48
|
+
command: identity.command,
|
|
49
|
+
timeout,
|
|
50
|
+
async: false,
|
|
51
|
+
...identity.statusMessage !== void 0 ? { statusMessage: identity.statusMessage } : {}
|
|
52
|
+
};
|
|
53
|
+
const id = {
|
|
54
|
+
event_name: EVENT_LABELS[identity.event],
|
|
55
|
+
...identity.matcher !== void 0 ? { matcher: identity.matcher } : {},
|
|
56
|
+
hooks: [handler]
|
|
57
|
+
};
|
|
58
|
+
const serialized = JSON.stringify(canonicalize(id));
|
|
59
|
+
return "sha256:" + crypto.createHash("sha256").update(serialized, "utf8").digest("hex");
|
|
60
|
+
}
|
|
61
|
+
function codexHookStateKey(hooksJsonPath, event, groupIndex, handlerIndex) {
|
|
62
|
+
return `${hooksJsonPath}:${EVENT_LABELS[event]}:${groupIndex}:${handlerIndex}`;
|
|
63
|
+
}
|
|
64
|
+
function parseCodexHooksState(toml) {
|
|
65
|
+
const state = {};
|
|
66
|
+
let currentKey = null;
|
|
67
|
+
for (const line of toml.split("\n")) {
|
|
68
|
+
const header = line.trim();
|
|
69
|
+
if (/^\[\[?[^\]]*\]\]?$/.test(header) || /^\[hooks\.state\."/.test(header)) {
|
|
70
|
+
const m = header.match(/^\[hooks\.state\."(.+)"\]$/);
|
|
71
|
+
currentKey = m ? m[1] : null;
|
|
72
|
+
if (currentKey !== null) state[currentKey] ??= {};
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (currentKey === null) continue;
|
|
76
|
+
const hash = line.match(/^\s*trusted_hash\s*=\s*"([^"]*)"\s*$/);
|
|
77
|
+
if (hash) {
|
|
78
|
+
state[currentKey].trustedHash = hash[1];
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const enabled = line.match(/^\s*enabled\s*=\s*(true|false)\s*$/);
|
|
82
|
+
if (enabled) state[currentKey].enabled = enabled[1] === "true";
|
|
83
|
+
}
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
function parseCodexFeatureHooks(toml) {
|
|
87
|
+
let inFeatures = false;
|
|
88
|
+
for (const line of toml.split("\n")) {
|
|
89
|
+
const header = line.trim();
|
|
90
|
+
if (/^\[\[?[^\]]+\]\]?$/.test(header)) {
|
|
91
|
+
inFeatures = header === "[features]";
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!inFeatures) continue;
|
|
95
|
+
const m = line.match(/^\s*hooks\s*=\s*(true|false)\s*$/);
|
|
96
|
+
if (m) return m[1] === "true";
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/doctor-codex.ts
|
|
102
|
+
function parseCodexEnvFromToml(toml) {
|
|
103
|
+
const env = {};
|
|
104
|
+
let inEnv = false;
|
|
105
|
+
for (const line of toml.split("\n")) {
|
|
106
|
+
const header = line.trim();
|
|
107
|
+
const isTableHeader = /^\[\[?[^\]]+\]\]?$/.test(header);
|
|
108
|
+
if (isTableHeader) {
|
|
109
|
+
inEnv = header === "[mcp_servers.kojee.env]";
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!inEnv) continue;
|
|
113
|
+
const eq = line.indexOf("=");
|
|
114
|
+
if (eq <= 0) continue;
|
|
115
|
+
const key = line.slice(0, eq).trim();
|
|
116
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(key)) continue;
|
|
117
|
+
let value = line.slice(eq + 1).trim();
|
|
118
|
+
const quote = value[0];
|
|
119
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
120
|
+
value = value.slice(1, -1);
|
|
121
|
+
if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
122
|
+
}
|
|
123
|
+
env[key] = value;
|
|
124
|
+
}
|
|
125
|
+
return env;
|
|
126
|
+
}
|
|
127
|
+
var WIZARD_RERUN = "re-run `kojee-mcp init --runtime codex`";
|
|
128
|
+
function extractPairedConfigPath(toml) {
|
|
129
|
+
const m = toml.match(/args\s*=\s*\[[^\]]*"--paired-config"\s*,\s*"([^"]+)"/);
|
|
130
|
+
return m ? m[1].replace(/\\\\/g, "\\") : null;
|
|
131
|
+
}
|
|
132
|
+
function defaultReadFile(path) {
|
|
133
|
+
return () => {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readFileSync(path, "utf8");
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function collectCodexDoctorReport(deps = {}) {
|
|
142
|
+
const readConfigToml = deps.readConfigToml ?? defaultReadFile(defaultCodexConfigPath());
|
|
143
|
+
const readHooksJson = deps.readHooksJson ?? defaultReadFile(defaultCodexHooksPath());
|
|
144
|
+
const checks = [];
|
|
145
|
+
const toml = readConfigToml() ?? "";
|
|
146
|
+
const env = deps.env ?? parseCodexEnvFromToml(toml);
|
|
147
|
+
const hasKojeeTable = toml.includes("[mcp_servers.kojee]");
|
|
148
|
+
const hasRuntimeEnv = /KOJEE_RUNTIME\s*=\s*"codex"/.test(toml);
|
|
149
|
+
const configOk = hasKojeeTable && hasRuntimeEnv;
|
|
150
|
+
checks.push({
|
|
151
|
+
name: "~/.codex/config.toml [mcp_servers.kojee]",
|
|
152
|
+
ok: configOk,
|
|
153
|
+
detail: configOk ? 'present with env.KOJEE_RUNTIME="codex" (Tier-1 contract)' : `MISSING [mcp_servers.kojee] or env.KOJEE_RUNTIME="codex" \u2014 ${WIZARD_RERUN}`
|
|
154
|
+
});
|
|
155
|
+
const hooksRaw = readHooksJson() ?? "{}";
|
|
156
|
+
let hooksJson = {};
|
|
157
|
+
try {
|
|
158
|
+
hooksJson = JSON.parse(hooksRaw);
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
let hookPresent = !!hooksJson.hooks?.["Stop"]?.some(
|
|
162
|
+
(e) => e.hooks?.some((h) => (h.command ?? "").includes("hook --type=codex-stop"))
|
|
163
|
+
);
|
|
164
|
+
if (!hookPresent && /hook --type=codex-stop/.test(toml)) hookPresent = true;
|
|
165
|
+
checks.push({
|
|
166
|
+
name: "Codex Stop hook (codex-stop)",
|
|
167
|
+
ok: hookPresent,
|
|
168
|
+
detail: hookPresent ? "present (fast PEEK + model-chosen bounded listen)" : `MISSING in ~/.codex/hooks.json / [[hooks.Stop]] \u2014 ${WIZARD_RERUN}`
|
|
169
|
+
});
|
|
170
|
+
const upsPresent = !!hooksJson.hooks?.["UserPromptSubmit"]?.some(
|
|
171
|
+
(e) => e.hooks?.some((h) => (h.command ?? "").includes("hook --type=codex-prompt-submit"))
|
|
172
|
+
);
|
|
173
|
+
checks.push({
|
|
174
|
+
name: "Codex UserPromptSubmit hook (codex-prompt-submit)",
|
|
175
|
+
ok: upsPresent ? true : "warn",
|
|
176
|
+
detail: upsPresent ? "present (pending-wake catch-up at the next prompt)" : `MISSING \u2014 a silently-dropped Stop block is never re-surfaced. ${WIZARD_RERUN}`
|
|
177
|
+
});
|
|
178
|
+
const featureHooks = parseCodexFeatureHooks(toml);
|
|
179
|
+
if (featureHooks !== true) {
|
|
180
|
+
checks.push({
|
|
181
|
+
name: "[features] hooks",
|
|
182
|
+
ok: "warn",
|
|
183
|
+
detail: (featureHooks === false ? "hooks = false in ~/.codex/config.toml [features]" : "no `hooks = true` under [features] in ~/.codex/config.toml") + " \u2014 Codex ignores hooks.json entirely; wakes silently stop. Enable the hooks feature."
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
checks.push({ name: "[features] hooks", ok: true, detail: "enabled" });
|
|
187
|
+
}
|
|
188
|
+
const hooksPath = deps.hooksPath ?? defaultCodexHooksPath();
|
|
189
|
+
const hooksState = parseCodexHooksState(toml);
|
|
190
|
+
for (const event of ["Stop", "UserPromptSubmit"]) {
|
|
191
|
+
const groups = hooksJson.hooks?.[event] ?? [];
|
|
192
|
+
for (let g = 0; g < groups.length; g++) {
|
|
193
|
+
const handlers = groups[g].hooks ?? [];
|
|
194
|
+
for (let h = 0; h < handlers.length; h++) {
|
|
195
|
+
const command = handlers[h].command ?? "";
|
|
196
|
+
if (!/hook --type=codex-(stop|prompt-submit)/.test(command)) continue;
|
|
197
|
+
const expected = codexHookTrustHash({
|
|
198
|
+
event,
|
|
199
|
+
command,
|
|
200
|
+
...handlers[h].timeout !== void 0 ? { timeout: handlers[h].timeout } : {}
|
|
201
|
+
});
|
|
202
|
+
const key = codexHookStateKey(hooksPath, event, g, h);
|
|
203
|
+
const recorded = hooksState[key]?.trustedHash;
|
|
204
|
+
const label = command.includes("codex-stop") ? "codex-stop" : "codex-prompt-submit";
|
|
205
|
+
if (recorded === expected) {
|
|
206
|
+
checks.push({
|
|
207
|
+
name: `hook trust (${event})`,
|
|
208
|
+
ok: true,
|
|
209
|
+
detail: `${label} trusted (trusted_hash matches the current hooks.json entry)`
|
|
210
|
+
});
|
|
211
|
+
} else if (recorded === void 0) {
|
|
212
|
+
checks.push({
|
|
213
|
+
name: `hook trust (${event})`,
|
|
214
|
+
ok: "warn",
|
|
215
|
+
detail: `${label} is UNTRUSTED \u2014 no [hooks.state]."${key}" trusted_hash. Codex will NOT run it (wakes silently stop) until you launch Codex and APPROVE its trust prompt.`
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
checks.push({
|
|
219
|
+
name: `hook trust (${event})`,
|
|
220
|
+
ok: "warn",
|
|
221
|
+
detail: `${label} trust is STALE \u2014 trusted_hash no longer matches the current hooks.json entry (a rewrite invalidated it). Codex will NOT run it until you re-approve the trust prompt.`
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const resolution = resolveWebhookConfig(env);
|
|
228
|
+
if (resolution.enabled && resolution.config) {
|
|
229
|
+
checks.push({
|
|
230
|
+
name: "webhook sink",
|
|
231
|
+
ok: true,
|
|
232
|
+
detail: `enabled \u2014 ${resolution.config.redactedSummary}`
|
|
233
|
+
});
|
|
234
|
+
if (resolution.warning) {
|
|
235
|
+
checks.push({
|
|
236
|
+
name: "webhook signature config",
|
|
237
|
+
ok: "warn",
|
|
238
|
+
detail: `${resolution.warning} \u2014 ${WIZARD_RERUN} with valid signature flags`
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
} else if (resolution.error) {
|
|
242
|
+
checks.push({
|
|
243
|
+
name: "webhook sink",
|
|
244
|
+
ok: false,
|
|
245
|
+
detail: `${resolution.error} \u2014 ${WIZARD_RERUN} (it generates a secret)`
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
checks.push({
|
|
249
|
+
name: "webhook sink",
|
|
250
|
+
ok: true,
|
|
251
|
+
detail: "optional \u2014 OFF (no KOJEE_WEBHOOK_URL). Codex wake is self-contained: the proxy records every event into the per-room pending ledger. Configure a receiver only for an extra low-latency push path."
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const configuredUrl = (env["KOJEE_WEBHOOK_URL"] ?? "").trim();
|
|
255
|
+
if (configuredUrl && isPlaceholderWebhookUrl(configuredUrl)) {
|
|
256
|
+
checks.push({
|
|
257
|
+
name: "webhook url placeholder",
|
|
258
|
+
ok: "warn",
|
|
259
|
+
detail: `KOJEE_WEBHOOK_URL is the legacy placeholder (${configuredUrl}) \u2014 the sink POSTs every event at a non-resolving host. Re-run \`kojee-mcp connect <code> --runtime codex\` (it scrubs the placeholder), or delete KOJEE_WEBHOOK_URL + KOJEE_WEBHOOK_SECRET from [mcp_servers.kojee.env]. Codex wake is self-contained and unaffected.`
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const pairedPath = extractPairedConfigPath(toml);
|
|
263
|
+
if (pairedPath) {
|
|
264
|
+
const present = fs.existsSync(pairedPath);
|
|
265
|
+
checks.push({
|
|
266
|
+
name: "paired-config slot",
|
|
267
|
+
ok: present,
|
|
268
|
+
detail: present ? `present \u2014 proxy launches with --paired-config ${pairedPath}` : `MISSING referenced config ${pairedPath} \u2014 re-run \`kojee-mcp connect <code> --runtime codex\``
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (deps.pingReceiver) {
|
|
272
|
+
const reachable = deps.pingReceiver();
|
|
273
|
+
checks.push({
|
|
274
|
+
name: "webhook receiver (optional ping)",
|
|
275
|
+
ok: reachable ? true : "warn",
|
|
276
|
+
detail: reachable ? "reachable" : "unreachable \u2014 stand up your receiver at KOJEE_WEBHOOK_URL (owner-built; see doctor note)"
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const readStatusJsons = deps.readStatusJsons ?? readAllCodexStatusRaw;
|
|
280
|
+
const isPidAlive = deps.isPidAlive ?? ((pid) => {
|
|
281
|
+
try {
|
|
282
|
+
process.kill(pid, 0);
|
|
283
|
+
return true;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return err?.code === "EPERM";
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
const now = deps.now ?? Date.now;
|
|
289
|
+
const statuses = readStatusJsons().map((raw) => readCodexStatus(raw)).filter((s) => s !== null);
|
|
290
|
+
const liveStatuses = statuses.filter((s) => isPidAlive(s.pid));
|
|
291
|
+
if (statuses.length === 0) {
|
|
292
|
+
checks.push({
|
|
293
|
+
name: "live proxy telemetry (~/.kojee/codex-status-<pid>.json)",
|
|
294
|
+
ok: "warn",
|
|
295
|
+
detail: `no live codex proxy status found \u2014 the proxy is not running (no codex window hosts the kojee MCP server), or it runs a pre-${VERSION} build that writes no telemetry (the stale-global npx trap: \`npm i -g kojee-mcp@latest\`, re-run \`kojee-mcp connect <code> --runtime codex\` \u2014 it pins the launcher version \u2014 then restart the Codex windows).`
|
|
296
|
+
});
|
|
297
|
+
} else if (liveStatuses.length === 0) {
|
|
298
|
+
const last = statuses.slice().sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at)).pop();
|
|
299
|
+
checks.push({
|
|
300
|
+
name: "live proxy telemetry (~/.kojee/codex-status-<pid>.json)",
|
|
301
|
+
ok: "warn",
|
|
302
|
+
detail: `stale \u2014 written by pid ${last.pid} which is no longer running (last updated ${last.updated_at}). Restart the Codex window to relaunch the proxy.`
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
const multi = liveStatuses.length > 1;
|
|
306
|
+
for (const status of liveStatuses.slice().sort((a, b) => a.pid - b.pid)) {
|
|
307
|
+
const tag = multi ? ` [pid ${status.pid}]` : "";
|
|
308
|
+
const updatedAtMs = Date.parse(status.updated_at);
|
|
309
|
+
const ageMs = Number.isFinite(updatedAtMs) ? now() - updatedAtMs : Number.POSITIVE_INFINITY;
|
|
310
|
+
const fresh = ageMs <= CODEX_STATUS_STALE_MS;
|
|
311
|
+
if (!fresh) {
|
|
312
|
+
checks.push({
|
|
313
|
+
name: `live proxy telemetry (~/.kojee/codex-status-<pid>.json)${tag}`,
|
|
314
|
+
ok: "warn",
|
|
315
|
+
detail: `stale \u2014 pid ${status.pid} is alive but the status heartbeat stopped ${Math.round(ageMs / 1e3)}s ago (last updated ${status.updated_at}) \u2014 hung proxy?`
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
checks.push({
|
|
320
|
+
name: `live proxy telemetry (~/.kojee/codex-status-<pid>.json)${tag}`,
|
|
321
|
+
ok: true,
|
|
322
|
+
detail: `pid ${status.pid} alive, heartbeat fresh (updated ${status.updated_at})`
|
|
323
|
+
});
|
|
324
|
+
const streamOk = status.stream.connected === true;
|
|
325
|
+
checks.push({
|
|
326
|
+
name: `event stream (live)${tag}`,
|
|
327
|
+
ok: streamOk,
|
|
328
|
+
detail: streamOk ? `pid ${status.pid} connected \u2014 last event ${status.stream.last_event_at ?? "never"}, reconnects ${status.stream.reconnects ?? "?"}, ${status.delivered_events} events delivered this run` : `pid ${status.pid} NOT CONNECTED (reconnects ${status.stream.reconnects ?? "?"}) \u2014 the proxy is up but hears no Tandem events: no wake marker will be written. Check broker reachability / credentials, then restart the Codex window.`
|
|
329
|
+
});
|
|
330
|
+
if (status.proxy_version !== VERSION) {
|
|
331
|
+
checks.push({
|
|
332
|
+
name: `running proxy version${tag}`,
|
|
333
|
+
ok: "warn",
|
|
334
|
+
detail: `the live proxy runs ${status.proxy_version} but this doctor is ${VERSION} \u2014 restart the Codex windows (and re-run connect to re-pin the launcher) so the running build matches the installed one.`
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (status.subscribed_tandem_count === 0) {
|
|
338
|
+
checks.push({
|
|
339
|
+
name: `stream subscriptions${tag}`,
|
|
340
|
+
ok: "warn",
|
|
341
|
+
detail: "stream up but subscribed to 0 tandems \u2014 join a Tandem (or check the resubscribe-on-connect belt) before expecting wakes."
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const verdict = checks.some((c) => c.ok === false) ? "broken" : checks.some((c) => c.ok === "warn") ? "degraded" : "healthy";
|
|
347
|
+
return { checks, verdict };
|
|
348
|
+
}
|
|
349
|
+
function formatCodexDoctorReport(report) {
|
|
350
|
+
const mark = (ok) => ok === true ? "\u2713" : ok === "warn" ? "\u26A0" : ok === "unknown" ? "?" : "\u2717";
|
|
351
|
+
const lines = [];
|
|
352
|
+
lines.push(`kojee-mcp doctor (codex) \u2014 verdict: ${report.verdict.toUpperCase()}`);
|
|
353
|
+
lines.push("");
|
|
354
|
+
lines.push(
|
|
355
|
+
" Wake mode: self-contained (proxy-written per-room pending ledger) + stop-hook peek (Codex has no channel injection). Webhook OPTIONAL, never required."
|
|
356
|
+
);
|
|
357
|
+
lines.push(` Bounded-listen cap: ${CODEX_LISTEN_CAP_MS}ms (model picks listen vs drain vs ignore).`);
|
|
358
|
+
lines.push("");
|
|
359
|
+
for (const c of report.checks) {
|
|
360
|
+
lines.push(` ${mark(c.ok)} ${c.name}: ${c.detail}`);
|
|
361
|
+
}
|
|
362
|
+
lines.push("");
|
|
363
|
+
lines.push("NOTE: live Codex verification (hook fires, MCP connects, bounded listen) is an owner step.");
|
|
364
|
+
return lines.join("\n");
|
|
365
|
+
}
|
|
366
|
+
export {
|
|
367
|
+
collectCodexDoctorReport,
|
|
368
|
+
formatCodexDoctorReport,
|
|
369
|
+
parseCodexEnvFromToml
|
|
370
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
LOG_HEARTBEAT_INTERVAL_MS,
|
|
3
|
+
UNARMED_FALLBACK_MS,
|
|
2
4
|
UNDICI_DEFAULT_BODY_TIMEOUT_MS,
|
|
3
5
|
createAdaptiveWatchdog,
|
|
4
6
|
createBackoffController,
|
|
@@ -7,10 +9,12 @@ import {
|
|
|
7
9
|
serializeCursorMap,
|
|
8
10
|
startEventStream,
|
|
9
11
|
statusReason
|
|
10
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-HI42GBQ3.js";
|
|
11
13
|
import "./chunk-Z5LPNJQ6.js";
|
|
12
14
|
import "./chunk-MIEI4PLB.js";
|
|
13
15
|
export {
|
|
16
|
+
LOG_HEARTBEAT_INTERVAL_MS,
|
|
17
|
+
UNARMED_FALLBACK_MS,
|
|
14
18
|
UNDICI_DEFAULT_BODY_TIMEOUT_MS,
|
|
15
19
|
createAdaptiveWatchdog,
|
|
16
20
|
createBackoffController,
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
listTandemIds,
|
|
3
3
|
startProxy
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-R5GC2GRD.js";
|
|
5
|
+
import "./chunk-E35VWFZV.js";
|
|
6
6
|
import "./chunk-247WFMCJ.js";
|
|
7
|
-
import "./chunk-I67C2HYA.js";
|
|
8
7
|
import "./chunk-Z5LPNJQ6.js";
|
|
8
|
+
import "./chunk-I67C2HYA.js";
|
|
9
9
|
import "./chunk-MIEI4PLB.js";
|
|
10
10
|
import "./chunk-6G6YYST6.js";
|
|
11
11
|
import "./chunk-U5HHHRXA.js";
|
|
12
|
-
import "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
import "./chunk-ZUIYFRO5.js";
|
|
13
|
+
import "./chunk-5DHIUN73.js";
|
|
14
|
+
import "./chunk-XFGGMDZ4.js";
|
|
14
15
|
import "./chunk-PPTKGWFF.js";
|
|
15
16
|
import "./chunk-XJEBJIQE.js";
|
|
16
17
|
import "./chunk-KNEJTD6G.js";
|
package/dist/lib.d.ts
CHANGED
|
@@ -487,33 +487,39 @@ declare function applyStableSessionId(token: string, deps?: StableSessionDeps):
|
|
|
487
487
|
*/
|
|
488
488
|
declare function deriveStableSessionId(token: string, instanceKey: string): string;
|
|
489
489
|
|
|
490
|
-
/**
|
|
491
|
-
* Flat tool registry — fetches all Kojee tools with full schemas on startup
|
|
492
|
-
* and registers them directly with the MCP server. No discovery indirection,
|
|
493
|
-
* no meta-tools, no call_tool wrapper.
|
|
494
|
-
*
|
|
495
|
-
* The agent sees each Kojee tool directly (e.g. gmail_send_email, github_list_repos)
|
|
496
|
-
* and calls it like any native MCP tool. Prompt caching handles the token cost
|
|
497
|
-
* of ~15K tokens after the first turn.
|
|
498
|
-
*/
|
|
499
490
|
declare class ToolRegistry {
|
|
500
491
|
private readonly gateway;
|
|
501
492
|
/** Flat map: tool name → full tool definition */
|
|
502
493
|
private tools;
|
|
494
|
+
/**
|
|
495
|
+
* LOCAL tools answered in-process by this proxy (e.g. codex's
|
|
496
|
+
* `tandem_pending` — per-window state the gateway cannot know). Local wins
|
|
497
|
+
* over a same-named gateway tool in both list and dispatch, so a future
|
|
498
|
+
* backend tool can never silently shadow a proxy-local answer.
|
|
499
|
+
*/
|
|
500
|
+
private localTools;
|
|
503
501
|
constructor(gateway: GatewayClient);
|
|
502
|
+
/**
|
|
503
|
+
* Register a proxy-local tool. Callers gate registration per-runtime (see
|
|
504
|
+
* tandem/pending-state.ts installPendingTool) — an un-opted runtime's tool
|
|
505
|
+
* list must stay byte-identical to the gateway's.
|
|
506
|
+
*/
|
|
507
|
+
registerLocalTool(definition: McpToolDefinition, handler: (args: Record<string, unknown>) => ToolCallResult | Promise<ToolCallResult>): void;
|
|
504
508
|
/**
|
|
505
509
|
* Fetch all tools with full schemas from the gateway in a single RPC call.
|
|
506
510
|
*/
|
|
507
511
|
discoverTools(): Promise<void>;
|
|
508
512
|
/**
|
|
509
|
-
* Return all registered tools for the MCP ListTools response
|
|
513
|
+
* Return all registered tools for the MCP ListTools response — gateway tools
|
|
514
|
+
* first (minus any a local tool shadows), then local tools.
|
|
510
515
|
*/
|
|
511
516
|
getAllTools(): McpToolDefinition[];
|
|
512
517
|
/**
|
|
513
|
-
* Call a tool
|
|
518
|
+
* Call a tool — local tools are answered in-process; everything else goes
|
|
519
|
+
* through the gateway.
|
|
514
520
|
*/
|
|
515
521
|
callTool(name: string, args: Record<string, unknown>): Promise<ToolCallResult>;
|
|
516
|
-
/** Total number of registered tools. */
|
|
522
|
+
/** Total number of registered tools (gateway + local, shadowed names once). */
|
|
517
523
|
get toolCount(): number;
|
|
518
524
|
}
|
|
519
525
|
|
|
@@ -527,11 +533,30 @@ interface ToolCallHooks {
|
|
|
527
533
|
* The proxy wires this to the debounced stream-reconnect scheduler so the
|
|
528
534
|
* event-stream's membership snapshot picks up the just-acquired EXPLICIT seat
|
|
529
535
|
* without a daemon restart. A throwing hook never breaks the tool reply.
|
|
530
|
-
* (
|
|
531
|
-
*
|
|
532
|
-
*
|
|
536
|
+
* (A leave needs no STREAM action — the stream stops hearing the room on the
|
|
537
|
+
* next reconnect; onTandemLeave below exists only for per-window pending
|
|
538
|
+
* state.)
|
|
533
539
|
*/
|
|
534
540
|
onTandemJoin?: (tandemId: string | null) => void;
|
|
541
|
+
/**
|
|
542
|
+
* Fired after a SUCCESSFUL tandem_leave performed through this proxy, with
|
|
543
|
+
* the left tandem id (null if unreadable). Wired today only for runtimes
|
|
544
|
+
* carrying a per-window SessionPendingState (codex — tandem/pending-state.ts)
|
|
545
|
+
* so the `tandem_pending` tool stops reporting a room the agent explicitly
|
|
546
|
+
* left. A throwing hook never breaks the tool reply.
|
|
547
|
+
*/
|
|
548
|
+
onTandemLeave?: (tandemId: string | null) => void;
|
|
549
|
+
/**
|
|
550
|
+
* Fired after a SUCCESSFUL tandem_messages/tandem_ack performed through this
|
|
551
|
+
* proxy, with the room and the highest cursor the reply/args carried. The
|
|
552
|
+
* proxy wires this to the general pending ledger's recordDrained
|
|
553
|
+
* (delivery/pending-ledger.ts): CONSUMPTION of a pending wake is PROXY-SIDE
|
|
554
|
+
* ONLY — a runtime hook surface (e.g. the codex stop-hook) merely peeks and
|
|
555
|
+
* never consumes, so a session blocking on another session's room can never
|
|
556
|
+
* eat that room's wake (Guy's live defect D2). A throwing hook never breaks
|
|
557
|
+
* the tool reply.
|
|
558
|
+
*/
|
|
559
|
+
onTandemDrain?: (tandemId: string, cursor: number) => void;
|
|
535
560
|
}
|
|
536
561
|
|
|
537
562
|
/**
|
package/dist/lib.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
resubscribeMemberships
|
|
3
|
-
} from "./chunk-OT2GILXC.js";
|
|
4
1
|
import {
|
|
5
2
|
normalizeBackendEvent,
|
|
6
3
|
sanitizeDisplayname,
|
|
7
4
|
startEventStream
|
|
8
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-HI42GBQ3.js";
|
|
6
|
+
import {
|
|
7
|
+
resubscribeMemberships
|
|
8
|
+
} from "./chunk-OT2GILXC.js";
|
|
9
9
|
import {
|
|
10
10
|
loadPairedConfig,
|
|
11
11
|
pairedConfigPath
|
|
@@ -14,12 +14,12 @@ import {
|
|
|
14
14
|
GatewayClient,
|
|
15
15
|
applyStableSessionId
|
|
16
16
|
} from "./chunk-247WFMCJ.js";
|
|
17
|
-
import {
|
|
18
|
-
AuthModule
|
|
19
|
-
} from "./chunk-I67C2HYA.js";
|
|
20
17
|
import {
|
|
21
18
|
deriveStableSessionId
|
|
22
19
|
} from "./chunk-Z5LPNJQ6.js";
|
|
20
|
+
import {
|
|
21
|
+
AuthModule
|
|
22
|
+
} from "./chunk-I67C2HYA.js";
|
|
23
23
|
import {
|
|
24
24
|
createDPoPProof
|
|
25
25
|
} from "./chunk-MIEI4PLB.js";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordDrained
|
|
3
|
+
} from "./chunk-IZN7IZPW.js";
|
|
4
|
+
|
|
5
|
+
// src/tandem/pending-state.ts
|
|
6
|
+
var PENDING_TOOL_RUNTIMES = /* @__PURE__ */ new Set(["codex"]);
|
|
7
|
+
var LEDGER_DRAIN_RUNTIMES = /* @__PURE__ */ new Set(["codex"]);
|
|
8
|
+
var TANDEM_PENDING_TOOL_NAME = "tandem_pending";
|
|
9
|
+
var REJOIN_ADVICE = "if you were in rooms before a restart, rejoin with your previous seat_name \u2014 the room rebinds your seat";
|
|
10
|
+
var MAX_TRACKED_ROOMS = 256;
|
|
11
|
+
function usableCursor(cursor) {
|
|
12
|
+
if (!Number.isFinite(cursor) || cursor < 0) return null;
|
|
13
|
+
return Math.floor(cursor);
|
|
14
|
+
}
|
|
15
|
+
function createSessionPendingState() {
|
|
16
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
17
|
+
let clock = 0;
|
|
18
|
+
const touch = (tandemId) => {
|
|
19
|
+
let rec = rooms.get(tandemId);
|
|
20
|
+
if (rec === void 0) {
|
|
21
|
+
rec = { seat: false, delivered: 0, drained: 0, touched: 0 };
|
|
22
|
+
rooms.set(tandemId, rec);
|
|
23
|
+
evictIfOverCap(tandemId);
|
|
24
|
+
}
|
|
25
|
+
rec.touched = ++clock;
|
|
26
|
+
return rec;
|
|
27
|
+
};
|
|
28
|
+
const evictIfOverCap = (justInserted) => {
|
|
29
|
+
while (rooms.size > MAX_TRACKED_ROOMS) {
|
|
30
|
+
let victim = null;
|
|
31
|
+
let victimTouched = Number.POSITIVE_INFINITY;
|
|
32
|
+
let victimPending = true;
|
|
33
|
+
for (const [id, rec] of rooms) {
|
|
34
|
+
if (id === justInserted) continue;
|
|
35
|
+
const isPending = rec.delivered > rec.drained;
|
|
36
|
+
const better = victimPending && !isPending || victimPending === isPending && rec.touched < victimTouched;
|
|
37
|
+
if (better) {
|
|
38
|
+
victim = id;
|
|
39
|
+
victimTouched = rec.touched;
|
|
40
|
+
victimPending = isPending;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (victim === null) return;
|
|
44
|
+
rooms.delete(victim);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
noteSeat(tandemId) {
|
|
49
|
+
if (tandemId) touch(tandemId).seat = true;
|
|
50
|
+
},
|
|
51
|
+
dropSeat(tandemId) {
|
|
52
|
+
rooms.delete(tandemId);
|
|
53
|
+
},
|
|
54
|
+
noteDelivered(tandemId, cursor) {
|
|
55
|
+
const next = usableCursor(cursor);
|
|
56
|
+
if (tandemId === "" || next === null) return;
|
|
57
|
+
const rec = touch(tandemId);
|
|
58
|
+
rec.delivered = Math.max(rec.delivered, next);
|
|
59
|
+
},
|
|
60
|
+
noteDrained(tandemId, cursor) {
|
|
61
|
+
const next = usableCursor(cursor);
|
|
62
|
+
if (tandemId === "" || next === null) return;
|
|
63
|
+
const rec = touch(tandemId);
|
|
64
|
+
rec.drained = Math.max(rec.drained, next);
|
|
65
|
+
},
|
|
66
|
+
snapshot(filterTandemId) {
|
|
67
|
+
const pending = [];
|
|
68
|
+
let sessionHasSeats = false;
|
|
69
|
+
for (const [tandemId, rec] of rooms) {
|
|
70
|
+
if (!rec.seat && rec.delivered === 0) continue;
|
|
71
|
+
sessionHasSeats = true;
|
|
72
|
+
if (filterTandemId !== void 0 && tandemId !== filterTandemId) continue;
|
|
73
|
+
if (rec.delivered > rec.drained) {
|
|
74
|
+
pending.push({
|
|
75
|
+
tandem_id: tandemId,
|
|
76
|
+
since_cursor: rec.drained,
|
|
77
|
+
delivered_cursor: rec.delivered
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const advice = !sessionHasSeats ? REJOIN_ADVICE : pending.length > 0 ? "drain each pending room with tandem_messages(tandem_id, since=since_cursor), then reply in the room" : "no pending Tandem events for this window";
|
|
82
|
+
return { pending, session_has_seats: sessionHasSeats, advice };
|
|
83
|
+
},
|
|
84
|
+
trackedRoomCount() {
|
|
85
|
+
return rooms.size;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function tandemPendingToolDefinition() {
|
|
90
|
+
return {
|
|
91
|
+
name: TANDEM_PENDING_TOOL_NAME,
|
|
92
|
+
description: "Report THIS window's pending Tandem events \u2014 answered entirely from this proxy's in-process state (rooms this session holds seats in, cursors delivered on this proxy's stream, cursors drained through this proxy's tool calls). Precise per-window: it never reads shared machine-wide files. Call it when the turn-end bell says events may be pending, then drain each listed room with tandem_messages(tandem_id, since=since_cursor) and reply in the room. If it lists nothing, the bell was for another window \u2014 ignore it. After a proxy restart it correctly reports nothing until you rejoin (use your previous seat_name \u2014 the room rebinds your seat).",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
tandem_id: {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "Optional: report only this room."
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function installPendingTool(runtime, registry) {
|
|
105
|
+
if (!PENDING_TOOL_RUNTIMES.has(runtime)) return null;
|
|
106
|
+
const state = createSessionPendingState();
|
|
107
|
+
registry.registerLocalTool(tandemPendingToolDefinition(), (args) => {
|
|
108
|
+
const filter = typeof args["tandem_id"] === "string" && args["tandem_id"] !== "" ? args["tandem_id"] : void 0;
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text", text: JSON.stringify(state.snapshot(filter)) }],
|
|
111
|
+
isError: false
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
function createRuntimeDrainHook(runtime, state) {
|
|
117
|
+
const writesLedger = LEDGER_DRAIN_RUNTIMES.has(runtime);
|
|
118
|
+
if (!writesLedger && state === null) return void 0;
|
|
119
|
+
return (tandemId, cursor) => {
|
|
120
|
+
if (writesLedger) recordDrained(tandemId, cursor);
|
|
121
|
+
state?.noteDrained(tandemId, cursor);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export {
|
|
125
|
+
LEDGER_DRAIN_RUNTIMES,
|
|
126
|
+
MAX_TRACKED_ROOMS,
|
|
127
|
+
PENDING_TOOL_RUNTIMES,
|
|
128
|
+
REJOIN_ADVICE,
|
|
129
|
+
TANDEM_PENDING_TOOL_NAME,
|
|
130
|
+
createRuntimeDrainHook,
|
|
131
|
+
createSessionPendingState,
|
|
132
|
+
installPendingTool,
|
|
133
|
+
tandemPendingToolDefinition
|
|
134
|
+
};
|