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,132 @@
|
|
|
1
|
+
// src/delivery/pending-ledger.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
var LEDGER_MAX_ROOMS = 16;
|
|
6
|
+
function pendingRoomsPath() {
|
|
7
|
+
return path.join(os.homedir(), ".kojee", "pending-rooms");
|
|
8
|
+
}
|
|
9
|
+
function drainedRoomsPath() {
|
|
10
|
+
return pendingRoomsPath() + ".drained";
|
|
11
|
+
}
|
|
12
|
+
function legacyMarkerPath() {
|
|
13
|
+
return path.join(os.homedir(), ".kojee", "codex-pending");
|
|
14
|
+
}
|
|
15
|
+
function legacyAckPath() {
|
|
16
|
+
return legacyMarkerPath() + ".ack";
|
|
17
|
+
}
|
|
18
|
+
function readEntries(filePath) {
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const line of raw.split("\n")) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (trimmed === "") continue;
|
|
29
|
+
const parts = trimmed.split(/\s+/);
|
|
30
|
+
if (parts.length !== 2) continue;
|
|
31
|
+
const id = parts[0];
|
|
32
|
+
const cursor = Number.parseInt(parts[1], 10);
|
|
33
|
+
if (!Number.isFinite(cursor) || cursor < 0) continue;
|
|
34
|
+
const existing = out.findIndex((e) => e.id === id);
|
|
35
|
+
if (existing !== -1) {
|
|
36
|
+
const prev = out[existing];
|
|
37
|
+
out.splice(existing, 1);
|
|
38
|
+
out.push({ id, cursor: Math.max(prev.cursor, cursor) });
|
|
39
|
+
} else {
|
|
40
|
+
out.push({ id, cursor });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function writeEntries(filePath, entries) {
|
|
46
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
49
|
+
const body = entries.map((e) => `${e.id} ${e.cursor}`).join("\n") + (entries.length ? "\n" : "");
|
|
50
|
+
fs.writeFileSync(tmp, body, { mode: 384 });
|
|
51
|
+
fs.renameSync(tmp, filePath);
|
|
52
|
+
} catch {
|
|
53
|
+
try {
|
|
54
|
+
fs.unlinkSync(tmp);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function upsert(filePath, tandemId, cursor, minCursor) {
|
|
60
|
+
const id = String(tandemId ?? "").replace(/\s+/g, "");
|
|
61
|
+
if (id === "") return;
|
|
62
|
+
if (!Number.isFinite(cursor) || cursor < minCursor) return;
|
|
63
|
+
const next = Math.floor(cursor);
|
|
64
|
+
const entries = readEntries(filePath);
|
|
65
|
+
const existing = entries.findIndex((e) => e.id === id);
|
|
66
|
+
let value = next;
|
|
67
|
+
if (existing !== -1) {
|
|
68
|
+
value = Math.max(entries[existing].cursor, next);
|
|
69
|
+
entries.splice(existing, 1);
|
|
70
|
+
}
|
|
71
|
+
entries.push({ id, cursor: value });
|
|
72
|
+
while (entries.length > LEDGER_MAX_ROOMS) entries.shift();
|
|
73
|
+
writeEntries(filePath, entries);
|
|
74
|
+
}
|
|
75
|
+
function recordDelivered(tandemId, cursor) {
|
|
76
|
+
try {
|
|
77
|
+
upsert(pendingRoomsPath(), tandemId, cursor, 1);
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function recordDrained(tandemId, cursor) {
|
|
82
|
+
try {
|
|
83
|
+
upsert(drainedRoomsPath(), tandemId, cursor, 0);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function listPending() {
|
|
88
|
+
const delivered = readEntries(pendingRoomsPath());
|
|
89
|
+
const drained = new Map(readEntries(drainedRoomsPath()).map((e) => [e.id, e.cursor]));
|
|
90
|
+
const out = [];
|
|
91
|
+
try {
|
|
92
|
+
const body = fs.readFileSync(legacyMarkerPath(), "utf8").trim();
|
|
93
|
+
if (body !== "") {
|
|
94
|
+
let acked = null;
|
|
95
|
+
try {
|
|
96
|
+
acked = fs.readFileSync(legacyAckPath(), "utf8").trim();
|
|
97
|
+
} catch {
|
|
98
|
+
acked = null;
|
|
99
|
+
}
|
|
100
|
+
if (acked === null || acked !== body) {
|
|
101
|
+
const toks = body.split(/\s+/);
|
|
102
|
+
const parsedCursor = Number.parseInt(toks[0] ?? "", 10);
|
|
103
|
+
const cursor = Number.isFinite(parsedCursor) && parsedCursor >= 0 ? parsedCursor : 0;
|
|
104
|
+
const room = toks[1] ?? null;
|
|
105
|
+
if (room !== null) {
|
|
106
|
+
const drainedCursor = drained.get(room);
|
|
107
|
+
const inLedger = delivered.some((e) => e.id === room);
|
|
108
|
+
if (!inLedger && (drainedCursor === void 0 || drainedCursor < cursor)) {
|
|
109
|
+
out.push({ tandemId: room, cursor });
|
|
110
|
+
}
|
|
111
|
+
} else if (drained.size === 0) {
|
|
112
|
+
out.push({ tandemId: null, cursor });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
for (const entry of delivered) {
|
|
119
|
+
if (entry.cursor > (drained.get(entry.id) ?? 0)) {
|
|
120
|
+
out.push({ tandemId: entry.id, cursor: entry.cursor });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export {
|
|
127
|
+
pendingRoomsPath,
|
|
128
|
+
drainedRoomsPath,
|
|
129
|
+
recordDelivered,
|
|
130
|
+
recordDrained,
|
|
131
|
+
listPending
|
|
132
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
claudeCodeAdapter
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-E35VWFZV.js";
|
|
4
4
|
import {
|
|
5
5
|
GatewayClient,
|
|
6
6
|
applyStableSessionId
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
createMcpServer,
|
|
13
13
|
startMcpServer
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-ZUIYFRO5.js";
|
|
15
15
|
import {
|
|
16
16
|
findClaudeAncestorPid
|
|
17
17
|
} from "./chunk-XJEBJIQE.js";
|
|
@@ -29,6 +29,21 @@ var ToolRegistry = class {
|
|
|
29
29
|
gateway;
|
|
30
30
|
/** Flat map: tool name → full tool definition */
|
|
31
31
|
tools = /* @__PURE__ */ new Map();
|
|
32
|
+
/**
|
|
33
|
+
* LOCAL tools answered in-process by this proxy (e.g. codex's
|
|
34
|
+
* `tandem_pending` — per-window state the gateway cannot know). Local wins
|
|
35
|
+
* over a same-named gateway tool in both list and dispatch, so a future
|
|
36
|
+
* backend tool can never silently shadow a proxy-local answer.
|
|
37
|
+
*/
|
|
38
|
+
localTools = /* @__PURE__ */ new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Register a proxy-local tool. Callers gate registration per-runtime (see
|
|
41
|
+
* tandem/pending-state.ts installPendingTool) — an un-opted runtime's tool
|
|
42
|
+
* list must stay byte-identical to the gateway's.
|
|
43
|
+
*/
|
|
44
|
+
registerLocalTool(definition, handler) {
|
|
45
|
+
this.localTools.set(definition.name, { definition, handler });
|
|
46
|
+
}
|
|
32
47
|
/**
|
|
33
48
|
* Fetch all tools with full schemas from the gateway in a single RPC call.
|
|
34
49
|
*/
|
|
@@ -56,15 +71,37 @@ var ToolRegistry = class {
|
|
|
56
71
|
console.error(`[tools] Registered ${this.tools.size} tools from gateway`);
|
|
57
72
|
}
|
|
58
73
|
/**
|
|
59
|
-
* Return all registered tools for the MCP ListTools response
|
|
74
|
+
* Return all registered tools for the MCP ListTools response — gateway tools
|
|
75
|
+
* first (minus any a local tool shadows), then local tools.
|
|
60
76
|
*/
|
|
61
77
|
getAllTools() {
|
|
62
|
-
|
|
78
|
+
const gatewayTools = Array.from(this.tools.values()).filter(
|
|
79
|
+
(t) => !this.localTools.has(t.name)
|
|
80
|
+
);
|
|
81
|
+
const localDefs = Array.from(this.localTools.values()).map((t) => t.definition);
|
|
82
|
+
return [...gatewayTools, ...localDefs];
|
|
63
83
|
}
|
|
64
84
|
/**
|
|
65
|
-
* Call a tool
|
|
85
|
+
* Call a tool — local tools are answered in-process; everything else goes
|
|
86
|
+
* through the gateway.
|
|
66
87
|
*/
|
|
67
88
|
async callTool(name, args) {
|
|
89
|
+
const local = this.localTools.get(name);
|
|
90
|
+
if (local) {
|
|
91
|
+
try {
|
|
92
|
+
return await local.handler(args);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return {
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: "text",
|
|
98
|
+
text: `Local tool '${name}' failed: ${err?.message ?? String(err)}`
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
isError: true
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
68
105
|
if (!this.tools.has(name)) {
|
|
69
106
|
const available = Array.from(this.tools.keys()).slice(0, 10).join(", ");
|
|
70
107
|
return {
|
|
@@ -82,9 +119,9 @@ var ToolRegistry = class {
|
|
|
82
119
|
arguments: args
|
|
83
120
|
});
|
|
84
121
|
}
|
|
85
|
-
/** Total number of registered tools. */
|
|
122
|
+
/** Total number of registered tools (gateway + local, shadowed names once). */
|
|
86
123
|
get toolCount() {
|
|
87
|
-
return this.
|
|
124
|
+
return this.getAllTools().length;
|
|
88
125
|
}
|
|
89
126
|
};
|
|
90
127
|
|
|
@@ -218,9 +255,16 @@ async function startProxy(config) {
|
|
|
218
255
|
return true;
|
|
219
256
|
}
|
|
220
257
|
});
|
|
221
|
-
const
|
|
258
|
+
const { installPendingTool, createRuntimeDrainHook } = await import("./pending-state-6TVRR63P.js");
|
|
259
|
+
const pendingState = installPendingTool(adapter.runtime, registry);
|
|
260
|
+
const onTandemJoin = (tandemId) => {
|
|
222
261
|
joinReconnect.requestReconnect();
|
|
262
|
+
if (tandemId !== null) pendingState?.noteSeat(tandemId);
|
|
223
263
|
};
|
|
264
|
+
const onTandemLeave = pendingState ? (tandemId) => {
|
|
265
|
+
if (tandemId !== null) pendingState.dropSeat(tandemId);
|
|
266
|
+
} : void 0;
|
|
267
|
+
const onTandemDrain = createRuntimeDrainHook(adapter.runtime, pendingState);
|
|
224
268
|
const teardownSteps = [];
|
|
225
269
|
let shuttingDown = false;
|
|
226
270
|
function shutdown(reason) {
|
|
@@ -251,9 +295,12 @@ async function startProxy(config) {
|
|
|
251
295
|
}
|
|
252
296
|
console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
|
|
253
297
|
let server;
|
|
254
|
-
const { selectDelivery } = await import("./registry-
|
|
298
|
+
const { selectDelivery } = await import("./registry-42Y45L6I.js");
|
|
255
299
|
const delivery = selectDelivery(adapter.runtime, {
|
|
256
|
-
supportsChannels: adapter.supportsChannels
|
|
300
|
+
supportsChannels: adapter.supportsChannels,
|
|
301
|
+
// Per-window delivered mirror for the tandem_pending tool (codex only —
|
|
302
|
+
// other runtimes' deliveries ignore this even when passed).
|
|
303
|
+
...pendingState ? { onDelivered: (e) => pendingState.noteDelivered(e.tandem_id, e.cursor) } : {}
|
|
257
304
|
});
|
|
258
305
|
if (delivery) {
|
|
259
306
|
const started = await delivery.start({
|
|
@@ -266,7 +313,11 @@ async function startProxy(config) {
|
|
|
266
313
|
ccPid,
|
|
267
314
|
tandemMembershipCount,
|
|
268
315
|
listTandemIds: () => listTandemIds(gateway),
|
|
269
|
-
toolCallHooks: {
|
|
316
|
+
toolCallHooks: {
|
|
317
|
+
onTandemJoin,
|
|
318
|
+
...onTandemLeave ? { onTandemLeave } : {},
|
|
319
|
+
...onTandemDrain ? { onTandemDrain } : {}
|
|
320
|
+
},
|
|
270
321
|
onStreamReady: (handle) => {
|
|
271
322
|
activeStreamHandle = handle;
|
|
272
323
|
joinReconnect.notifyReady();
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
buildHookCommand,
|
|
3
3
|
isKojeeHookCommand,
|
|
4
4
|
npxHookCommand
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-TMCNB4JH.js";
|
|
6
6
|
import {
|
|
7
7
|
wrapNpxLauncher
|
|
8
8
|
} from "./chunk-D6JKFJ6A.js";
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
secureFile
|
|
14
14
|
} from "./chunk-U5HHHRXA.js";
|
|
15
|
+
import {
|
|
16
|
+
VERSION
|
|
17
|
+
} from "./chunk-5DHIUN73.js";
|
|
15
18
|
|
|
16
19
|
// src/wizard/codex-config.ts
|
|
17
20
|
import fs from "fs";
|
|
@@ -33,14 +36,22 @@ var CODEX_STOP_HOOK_COMMAND = npxHookCommand("codex-stop");
|
|
|
33
36
|
function codexStopHookCommand() {
|
|
34
37
|
return buildHookCommand("codex-stop");
|
|
35
38
|
}
|
|
39
|
+
function codexPromptSubmitHookCommand() {
|
|
40
|
+
return buildHookCommand("codex-prompt-submit");
|
|
41
|
+
}
|
|
42
|
+
var CODEX_HOOK_TIMEOUT_SEC = 10;
|
|
43
|
+
function codexNpxPackageSpec() {
|
|
44
|
+
return /^\d/.test(VERSION) ? `kojee-mcp@${VERSION}` : "kojee-mcp";
|
|
45
|
+
}
|
|
36
46
|
function codexRestArgs(token, url, pairedConfigPath) {
|
|
47
|
+
const spec = codexNpxPackageSpec();
|
|
37
48
|
if (token && url) {
|
|
38
|
-
return ["-y",
|
|
49
|
+
return ["-y", spec, "--token", token, "--url", url];
|
|
39
50
|
}
|
|
40
51
|
if (pairedConfigPath) {
|
|
41
|
-
return ["-y",
|
|
52
|
+
return ["-y", spec, "--paired-config", pairedConfigPath];
|
|
42
53
|
}
|
|
43
|
-
return ["-y",
|
|
54
|
+
return ["-y", spec];
|
|
44
55
|
}
|
|
45
56
|
function codexLauncherLiterals(token, url, pairedConfigPath) {
|
|
46
57
|
const rest = codexRestArgs(token, url, pairedConfigPath);
|
|
@@ -76,7 +87,17 @@ function buildCodexStopHookBlock() {
|
|
|
76
87
|
"[[hooks.Stop]]",
|
|
77
88
|
"[[hooks.Stop.hooks]]",
|
|
78
89
|
'type = "command"',
|
|
79
|
-
`command = "${escapeTomlString(codexStopHookCommand())}"
|
|
90
|
+
`command = "${escapeTomlString(codexStopHookCommand())}"`,
|
|
91
|
+
`timeout = ${CODEX_HOOK_TIMEOUT_SEC}`
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
function buildCodexPromptSubmitHookBlock() {
|
|
95
|
+
return [
|
|
96
|
+
"[[hooks.UserPromptSubmit]]",
|
|
97
|
+
"[[hooks.UserPromptSubmit.hooks]]",
|
|
98
|
+
'type = "command"',
|
|
99
|
+
`command = "${escapeTomlString(codexPromptSubmitHookCommand())}"`,
|
|
100
|
+
`timeout = ${CODEX_HOOK_TIMEOUT_SEC}`
|
|
80
101
|
].join("\n");
|
|
81
102
|
}
|
|
82
103
|
function escapeTomlString(s) {
|
|
@@ -84,6 +105,16 @@ function escapeTomlString(s) {
|
|
|
84
105
|
}
|
|
85
106
|
var KOJEE_TABLE_HEADER = "[mcp_servers.kojee]";
|
|
86
107
|
var KOJEE_ENV_TABLE_HEADER = "[mcp_servers.kojee.env]";
|
|
108
|
+
function isPlaceholderWebhookUrl(url) {
|
|
109
|
+
return /^https?:\/\/YOUR-[A-Z0-9-]*\.local(?=[/:?#]|$)/i.test(url.trim());
|
|
110
|
+
}
|
|
111
|
+
function scrubPlaceholderWebhookEnv(envKeys) {
|
|
112
|
+
const urlEntry = envKeys.find(([k]) => k === "KOJEE_WEBHOOK_URL");
|
|
113
|
+
if (!urlEntry) return envKeys;
|
|
114
|
+
const raw = urlEntry[1].trim().replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
|
|
115
|
+
if (!isPlaceholderWebhookUrl(raw)) return envKeys;
|
|
116
|
+
return envKeys.filter(([k]) => k !== "KOJEE_WEBHOOK_URL" && k !== "KOJEE_WEBHOOK_SECRET");
|
|
117
|
+
}
|
|
87
118
|
function writeCodexConfig(inputs) {
|
|
88
119
|
const configPath = inputs.configPath ?? defaultCodexConfigPath();
|
|
89
120
|
const hooksPath = inputs.hooksPath ?? defaultCodexHooksPath();
|
|
@@ -102,17 +133,21 @@ function writeCodexConfig(inputs) {
|
|
|
102
133
|
inputs.pairedConfigPath
|
|
103
134
|
);
|
|
104
135
|
writeFile600(configPath, toml);
|
|
105
|
-
const command = codexStopHookCommand();
|
|
106
136
|
const hooks = readJson(hooksPath);
|
|
107
137
|
hooks.hooks ??= {};
|
|
108
|
-
hooks
|
|
138
|
+
upsertKojeeHookEntry(hooks, "Stop", codexStopHookCommand());
|
|
139
|
+
upsertKojeeHookEntry(hooks, "UserPromptSubmit", codexPromptSubmitHookCommand());
|
|
140
|
+
writeFile600(hooksPath, JSON.stringify(hooks, null, 2));
|
|
141
|
+
}
|
|
142
|
+
function upsertKojeeHookEntry(hooks, event, command) {
|
|
143
|
+
hooks.hooks ??= {};
|
|
144
|
+
hooks.hooks[event] ??= [];
|
|
109
145
|
let present = false;
|
|
110
|
-
for (const e of hooks.hooks
|
|
146
|
+
for (const e of hooks.hooks[event]) {
|
|
111
147
|
for (const h of e.hooks ?? []) {
|
|
112
|
-
if (h.command === command) {
|
|
113
|
-
present = true;
|
|
114
|
-
} else if (isKojeeHookCommand(h.command)) {
|
|
148
|
+
if (h.command === command || isKojeeHookCommand(h.command)) {
|
|
115
149
|
h.command = command;
|
|
150
|
+
h.timeout = CODEX_HOOK_TIMEOUT_SEC;
|
|
116
151
|
present = true;
|
|
117
152
|
}
|
|
118
153
|
if (present) break;
|
|
@@ -120,11 +155,10 @@ function writeCodexConfig(inputs) {
|
|
|
120
155
|
if (present) break;
|
|
121
156
|
}
|
|
122
157
|
if (!present) {
|
|
123
|
-
hooks.hooks.
|
|
124
|
-
hooks: [{ type: "command", command }]
|
|
158
|
+
hooks.hooks[event].push({
|
|
159
|
+
hooks: [{ type: "command", command, timeout: CODEX_HOOK_TIMEOUT_SEC }]
|
|
125
160
|
});
|
|
126
161
|
}
|
|
127
|
-
writeFile600(hooksPath, JSON.stringify(hooks, null, 2));
|
|
128
162
|
}
|
|
129
163
|
function removeCodexConfig(opts = {}) {
|
|
130
164
|
const configPath = opts.configPath ?? defaultCodexConfigPath();
|
|
@@ -141,18 +175,23 @@ function removeCodexConfig(opts = {}) {
|
|
|
141
175
|
}
|
|
142
176
|
try {
|
|
143
177
|
const hooks = readJson(hooksPath);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
178
|
+
let changed = false;
|
|
179
|
+
for (const event of ["Stop", "UserPromptSubmit"]) {
|
|
180
|
+
const entries = hooks.hooks?.[event];
|
|
181
|
+
if (!entries || entries.length === 0) continue;
|
|
182
|
+
const kept = entries.filter(
|
|
148
183
|
// F10: strip kojee entries in EITHER persisted form (npx or pinned).
|
|
149
184
|
(e) => !e.hooks?.some((h) => isKojeeHookCommand(h.command))
|
|
150
185
|
);
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
if (kept.length !== entries.length) {
|
|
187
|
+
hooks.hooks[event] = kept;
|
|
188
|
+
changed = true;
|
|
154
189
|
}
|
|
155
190
|
}
|
|
191
|
+
if (changed) {
|
|
192
|
+
result.stopHook = true;
|
|
193
|
+
writeFile600(hooksPath, JSON.stringify(hooks, null, 2));
|
|
194
|
+
}
|
|
156
195
|
} catch {
|
|
157
196
|
}
|
|
158
197
|
return result;
|
|
@@ -176,7 +215,7 @@ function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret, signatureEnv
|
|
|
176
215
|
["command", commandLiteral],
|
|
177
216
|
["args", argsLiteral]
|
|
178
217
|
]);
|
|
179
|
-
const envKeys = upsertKeyLines(parsed.envKeys, [
|
|
218
|
+
const envKeys = upsertKeyLines(scrubPlaceholderWebhookEnv(parsed.envKeys), [
|
|
180
219
|
["KOJEE_RUNTIME", '"codex"'],
|
|
181
220
|
// Own KOJEE_WEBHOOK_URL/SECRET ONLY when supplied this run. The credential-
|
|
182
221
|
// only `connect` path passes neither → they drop out of the owned set and
|
|
@@ -299,6 +338,8 @@ export {
|
|
|
299
338
|
defaultCodexHooksPath,
|
|
300
339
|
buildCodexMcpServerTable,
|
|
301
340
|
buildCodexStopHookBlock,
|
|
341
|
+
buildCodexPromptSubmitHookBlock,
|
|
342
|
+
isPlaceholderWebhookUrl,
|
|
302
343
|
writeCodexConfig,
|
|
303
344
|
removeCodexConfig
|
|
304
345
|
};
|
|
@@ -20,7 +20,7 @@ function buildHookCommand(type, opts = {}) {
|
|
|
20
20
|
const execPath = opts.execPath ?? process.execPath;
|
|
21
21
|
return `"${execPath}" "${cliEntry}" hook --type=${type}`;
|
|
22
22
|
}
|
|
23
|
-
var PINNED_HOOK_RE = /^"[^"]+" "[^"]+" hook --type=(stop|user-prompt-submit|codex-stop)$/;
|
|
23
|
+
var PINNED_HOOK_RE = /^"[^"]+" "[^"]+" hook --type=(stop|user-prompt-submit|codex-stop|codex-prompt-submit)$/;
|
|
24
24
|
function isKojeeHookCommand(command) {
|
|
25
25
|
if (command.startsWith("npx -y kojee-mcp hook --type=")) return true;
|
|
26
26
|
return PINNED_HOOK_RE.test(command);
|
|
@@ -15,6 +15,9 @@ function buildReplyRecipe(event) {
|
|
|
15
15
|
function buildCatchUpNote() {
|
|
16
16
|
return "Cursors are allocated per-Tandem, and the event-log interleaves every subscribed room into one stream \u2014 so track the last `cursor=<n>` you saw keyed by the line's `tandem=<id>` field, and only treat a jump WITHIN the same tandem_id as a gap (a jump between lines from different rooms is normal interleaving, not a missed message). If the cursor jumps within one tandem, or the log resets (the proxy caps and truncates the messages log in place on overflow \u2014 a `status=rotated` line is recorded in the status sibling), you may have missed messages: catch up with tandem_messages(tandem_id, since=<last cursor you saw for THAT tandem>).";
|
|
17
17
|
}
|
|
18
|
+
function buildCondensedTandemRules() {
|
|
19
|
+
return "Tandem ground rules: (1) catch up before you speak \u2014 fetch messages since your last cursor (cursors are per-Tandem); never answer from a stale view. (2) close every loop \u2014 taking / done (with evidence) / blocked, said out loud; silence is a bug on a team. (3) your seat_name IS your room identity \u2014 keep it stable across sessions and reconnects. (4) plan your wake path per task and set your wake filter deliberately (heads-down \u2192 mentions; waiting on one reply \u2192 listen; co-working \u2192 all), and tell the room your posture WHEN IT CHANGES \u2014 skip the check-in ceremony when you are merely re-joining/reconnecting with the same posture.";
|
|
20
|
+
}
|
|
18
21
|
function buildMonitorNudge(logPath) {
|
|
19
22
|
return `[kojee] Tandem events are being logged but no Monitor is reading them \u2014 you may be missing wake notifications. Spawn the watcher once: ${buildMonitorSpawn(logPath)}. Then ${buildReplyRecipe()}.`;
|
|
20
23
|
}
|
|
@@ -26,17 +29,15 @@ function buildWebhookReceiverNote(sig) {
|
|
|
26
29
|
return "Webhook sink (optional, OFF unless KOJEE_WEBHOOK_URL + KOJEE_WEBHOOK_SECRET are set): the proxy POSTs every Tandem event as JSON to your endpoint. The body is the canonical normalized TandemEvent \u2014 " + WEBHOOK_BODY_SHAPE + ` \u2014 where from.session_id and severity are present only when the wire carried them (the body is fully normalized: it carries from.principal, never the raw backend sender envelope). To build a receiver: (1) verify the ${header} header \u2014 it is ${digestDesc} of the RAW request body bytes keyed by your KOJEE_WEBHOOK_SECRET; recompute over the received bytes and timing-safe compare, reject mismatches. (2) Dedupe by message_id \u2014 the body's \`id\`, also in the X-Kojee-Delivery header: delivery is AT-LEAST-ONCE (the proxy replays backlog from the cursor on restart), so the same event may arrive more than once \u2014 there is no exactly-once promise.`;
|
|
27
30
|
}
|
|
28
31
|
var CODEX_LISTEN_CAP_MS = 8e3;
|
|
29
|
-
|
|
30
|
-
const drainSince = Math.max(0, cursor - 1);
|
|
31
|
-
return `[kojee] new Tandem event(s) pending (cursor=${cursor}). If relevant to your task, drain them now: call tandem_messages(tandem_id, since=${drainSince}) to read, then ` + buildReplyRecipe() + `. If you expect an imminent reply and want to wait for exactly one, call tandem_listen(tandem_id, since=${cursor}, timeout_ms<=${CODEX_LISTEN_CAP_MS}) \u2014 a BOUNDED wait, cap 8s, NEVER an unconditional long-poll. If not relevant, ignore.`;
|
|
32
|
-
}
|
|
32
|
+
var CODEX_WAKE_BELL = "[kojee] Tandem events may be pending. Call tandem_pending now and drain each room it lists (tandem_messages(id, since=cursor)), then reply in the room. If it returns none for you, ignore this.";
|
|
33
33
|
|
|
34
34
|
export {
|
|
35
35
|
buildMonitorSpawn,
|
|
36
36
|
buildReplyRecipe,
|
|
37
37
|
buildCatchUpNote,
|
|
38
|
+
buildCondensedTandemRules,
|
|
38
39
|
buildMonitorNudge,
|
|
39
40
|
buildWebhookReceiverNote,
|
|
40
41
|
CODEX_LISTEN_CAP_MS,
|
|
41
|
-
|
|
42
|
+
CODEX_WAKE_BELL
|
|
42
43
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/delivery/codex-status.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import fsp from "fs/promises";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
var CODEX_STATUS_HEARTBEAT_MS = 6e4;
|
|
8
|
+
var CODEX_STATUS_STALE_MS = 5 * 6e4;
|
|
9
|
+
function codexStatusDir() {
|
|
10
|
+
return path.join(os.homedir(), ".kojee");
|
|
11
|
+
}
|
|
12
|
+
function codexStatusPath(pid = process.pid) {
|
|
13
|
+
return path.join(codexStatusDir(), `codex-status-${pid}.json`);
|
|
14
|
+
}
|
|
15
|
+
var STATUS_FILE_RE = /^codex-status(?:-(\d+))?\.json$/;
|
|
16
|
+
function readAllCodexStatusRaw() {
|
|
17
|
+
const dir = codexStatusDir();
|
|
18
|
+
let names;
|
|
19
|
+
try {
|
|
20
|
+
names = fs.readdirSync(dir);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const name of names.filter((n) => STATUS_FILE_RE.test(n)).sort()) {
|
|
26
|
+
try {
|
|
27
|
+
out.push(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
async function sweepDeadCodexStatusFiles(isPidAlive = defaultIsPidAlive) {
|
|
34
|
+
const dir = codexStatusDir();
|
|
35
|
+
let names;
|
|
36
|
+
try {
|
|
37
|
+
names = await fsp.readdir(dir);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const m = STATUS_FILE_RE.exec(name);
|
|
43
|
+
if (!m) continue;
|
|
44
|
+
const full = path.join(dir, name);
|
|
45
|
+
let pid = m[1] ? Number(m[1]) : null;
|
|
46
|
+
if (pid === null) {
|
|
47
|
+
try {
|
|
48
|
+
pid = readCodexStatus(await fsp.readFile(full, "utf8"))?.pid ?? null;
|
|
49
|
+
} catch {
|
|
50
|
+
pid = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (pid === process.pid) continue;
|
|
54
|
+
if (pid !== null && isPidAlive(pid)) continue;
|
|
55
|
+
try {
|
|
56
|
+
await fsp.unlink(full);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function defaultIsPidAlive(pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return err?.code === "EPERM";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function readCodexStatus(raw) {
|
|
70
|
+
if (!raw) return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.pid !== "number" || typeof parsed.updated_at !== "string" || typeof parsed.stream !== "object" || parsed.stream === null) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function writeCodexStatus(status) {
|
|
82
|
+
const statusPath = codexStatusPath(status.pid);
|
|
83
|
+
const tmp = `${statusPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
84
|
+
try {
|
|
85
|
+
await fsp.mkdir(path.dirname(statusPath), { recursive: true });
|
|
86
|
+
await fsp.writeFile(tmp, JSON.stringify(status, null, 2), { mode: 384 });
|
|
87
|
+
await fsp.rename(tmp, statusPath);
|
|
88
|
+
} catch {
|
|
89
|
+
try {
|
|
90
|
+
await fsp.unlink(tmp);
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
CODEX_STATUS_HEARTBEAT_MS,
|
|
98
|
+
CODEX_STATUS_STALE_MS,
|
|
99
|
+
readAllCodexStatusRaw,
|
|
100
|
+
sweepDeadCodexStatusFiles,
|
|
101
|
+
readCodexStatus,
|
|
102
|
+
writeCodexStatus
|
|
103
|
+
};
|