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.
Files changed (33) hide show
  1. package/dist/chunk-5DHIUN73.js +25 -0
  2. package/dist/{chunk-OB5T6P24.js → chunk-E35VWFZV.js} +1 -1
  3. package/dist/{chunk-GVWYW7MY.js → chunk-EDHG4375.js} +241 -58
  4. package/dist/{chunk-FSKGQ6GT.js → chunk-HI42GBQ3.js} +5 -0
  5. package/dist/chunk-IZN7IZPW.js +132 -0
  6. package/dist/{chunk-5H75AEZ2.js → chunk-R5GC2GRD.js} +62 -11
  7. package/dist/{chunk-OBIJ6WQP.js → chunk-RVLUZLXD.js} +63 -22
  8. package/dist/{chunk-QJFMU4QC.js → chunk-TMCNB4JH.js} +1 -1
  9. package/dist/{chunk-QEJUNP3X.js → chunk-XFGGMDZ4.js} +6 -5
  10. package/dist/chunk-XLRF5ATG.js +103 -0
  11. package/dist/{chunk-SGRVG4HW.js → chunk-ZUIYFRO5.js} +62 -30
  12. package/dist/cli.js +24 -14
  13. package/dist/codex-prompt-submit-hook-FOBPGZHJ.js +39 -0
  14. package/dist/codex-stop-hook-PNWYNQKM.js +136 -0
  15. package/dist/{connect-handler-HIRM624N.js → connect-handler-2JFIMQY6.js} +15 -7
  16. package/dist/{doctor-PLKLHTJM.js → doctor-HXMCO5PR.js} +2 -2
  17. package/dist/doctor-codex-ZSALZPH3.js +370 -0
  18. package/dist/{event-stream-KRYWEYWO.js → event-stream-WPN3EN7C.js} +5 -1
  19. package/dist/index.js +6 -5
  20. package/dist/{install-V7LSQCYZ.js → install-GGI6GU6C.js} +1 -1
  21. package/dist/lib.d.ts +40 -15
  22. package/dist/lib.js +7 -7
  23. package/dist/pending-state-6TVRR63P.js +134 -0
  24. package/dist/{registry-VRFHKOPP.js → registry-42Y45L6I.js} +114 -47
  25. package/dist/{server-YTHOMA4H.js → server-JWIH7OFA.js} +5 -2
  26. package/dist/{setup-handler-UHVKSX7E.js → setup-handler-GBPXDETR.js} +8 -7
  27. package/dist/{stop-hook-YI4KRKF2.js → stop-hook-PWTAP227.js} +2 -2
  28. package/dist/{tail-stream-NBGHHBS4.js → tail-stream-N43D53RC.js} +99 -19
  29. package/package.json +3 -2
  30. package/skills/using-tandems/SKILL.md +52 -0
  31. package/dist/chunk-WIU5WGDF.js +0 -10
  32. package/dist/codex-stop-hook-EOMQD3J4.js +0 -69
  33. 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-FSKGQ6GT.js";
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-5H75AEZ2.js";
5
- import "./chunk-OB5T6P24.js";
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-SGRVG4HW.js";
13
- import "./chunk-QEJUNP3X.js";
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";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  buildHookCommand,
3
3
  isKojeeHookCommand
4
- } from "./chunk-QJFMU4QC.js";
4
+ } from "./chunk-TMCNB4JH.js";
5
5
  import {
6
6
  wrapNpxLauncher
7
7
  } from "./chunk-D6JKFJ6A.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 through the gateway.
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
- * (There is no leave hook: auto-join removal deleted the room-memory it fed,
531
- * and a leave needs no proxy action — the stream stops hearing the room on the
532
- * next reconnect.)
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-FSKGQ6GT.js";
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
+ };