mcp-agents 0.12.0 → 0.12.1
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/README.md +12 -4
- package/package.json +1 -1
- package/server.js +270 -24
package/README.md
CHANGED
|
@@ -127,10 +127,18 @@ a concise `Reminder — standing objective for this thread: …` preamble on the
|
|
|
127
127
|
prompt. Any caller-supplied `developer-instructions` are preserved, with the
|
|
128
128
|
objective merged ahead of them.
|
|
129
129
|
|
|
130
|
-
The wrapper-only `goal` argument is always stripped before it reaches Codex (
|
|
131
|
-
|
|
132
|
-
call; a per-call empty `goal` (`""`) suppresses the default for
|
|
133
|
-
non-string `goal` is ignored (the `--goal` default still
|
|
130
|
+
The wrapper-only `goal` argument is always stripped before it reaches Codex (it
|
|
131
|
+
is never a native Codex parameter). A per-call `goal` overrides the `--goal`
|
|
132
|
+
default for that call; a per-call empty `goal` (`""`) suppresses the default for
|
|
133
|
+
that one call; a non-string `goal` is ignored (the `--goal` default still
|
|
134
|
+
applies).
|
|
135
|
+
|
|
136
|
+
So a client's model knows it can pass `goal`, the pass-through advertises it: it
|
|
137
|
+
rewrites its own `tools/list` response to declare an optional `goal` property on
|
|
138
|
+
the `codex` and `codex-reply` tool schemas (models only generate arguments
|
|
139
|
+
declared in a tool's `inputSchema`). Only `properties` is augmented — `required`
|
|
140
|
+
and `additionalProperties` are left intact — and the rewrite touches only the
|
|
141
|
+
`tools/list` response; every other frame is forwarded byte-for-byte.
|
|
134
142
|
|
|
135
143
|
**Precedence within a thread.** The objective set on the initial `codex` call is
|
|
136
144
|
a developer-role message and persists for the whole thread, so it takes
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -683,6 +683,48 @@ function filterCodexToolCall(line, opts = {}) {
|
|
|
683
683
|
return JSON.stringify(msg);
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
+
// Tools whose advertised inputSchema gains a wrapper-only `goal` property so a
|
|
687
|
+
// client's model knows it can pass one (the model only emits args declared in
|
|
688
|
+
// the schema). The arg is stripped inbound by filterCodexToolCall before it
|
|
689
|
+
// reaches codex; advertising it here is purely for discoverability.
|
|
690
|
+
const CODEX_GOAL_TOOLS = new Set(["codex", "codex-reply"]);
|
|
691
|
+
const CODEX_GOAL_PROPERTY_DESCRIPTION =
|
|
692
|
+
"Optional standing objective for this Codex session. mcp-agents injects it as " +
|
|
693
|
+
"`developer-instructions` (codex) or a prompt reminder (codex-reply); it is not a " +
|
|
694
|
+
"native Codex parameter. Overrides the server-wide --goal default for this call; " +
|
|
695
|
+
"pass an empty string to suppress that default.";
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Mutate a parsed `tools/list` RESPONSE in place, adding a `goal` property to the
|
|
699
|
+
* advertised inputSchema of the `codex` and `codex-reply` tools. Returns true iff
|
|
700
|
+
* it added `goal` to at least one tool. Only `properties` is touched — `required`
|
|
701
|
+
* and `additionalProperties` are left intact (a declared property is not an
|
|
702
|
+
* "additional" one, so `additionalProperties:false` stays valid). Best-effort per
|
|
703
|
+
* tool: a target tool that already declares `goal` (idempotent) or whose
|
|
704
|
+
* inputSchema.properties is missing/malformed (drifted schema) is simply skipped;
|
|
705
|
+
* other valid targets are still augmented. Returns false (→ the caller forwards the
|
|
706
|
+
* original bytes byte-for-byte) for an error response, a non-array `result.tools`,
|
|
707
|
+
* or when no `codex`/`codex-reply` target was augmentable.
|
|
708
|
+
* @param {any} msg
|
|
709
|
+
* @returns {boolean}
|
|
710
|
+
*/
|
|
711
|
+
function injectGoalIntoToolsListMessage(msg) {
|
|
712
|
+
const tools = msg?.result?.tools;
|
|
713
|
+
if (!Array.isArray(tools)) return false;
|
|
714
|
+
let changed = false;
|
|
715
|
+
for (const tool of tools) {
|
|
716
|
+
if (!tool || typeof tool !== "object" || !CODEX_GOAL_TOOLS.has(tool.name)) continue;
|
|
717
|
+
const schema = tool.inputSchema;
|
|
718
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) continue;
|
|
719
|
+
const props = schema.properties;
|
|
720
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) continue;
|
|
721
|
+
if ("goal" in props) continue; // idempotent — respect an existing declaration
|
|
722
|
+
props.goal = { type: "string", description: CODEX_GOAL_PROPERTY_DESCRIPTION };
|
|
723
|
+
changed = true;
|
|
724
|
+
}
|
|
725
|
+
return changed;
|
|
726
|
+
}
|
|
727
|
+
|
|
686
728
|
/**
|
|
687
729
|
* Spawn codex mcp-server as a pass-through. codex stdout is forwarded back to
|
|
688
730
|
* the client byte-for-byte, but the client's stdin is intercepted line-by-line
|
|
@@ -812,6 +854,18 @@ function runCodexPassthrough({
|
|
|
812
854
|
let droppedFrameResponseId; // partial oversized frame's classified id (cleared at its newline)
|
|
813
855
|
let observationDropLogged = false; // log the first observation-cap drop only
|
|
814
856
|
|
|
857
|
+
// ── tools/list goal-advertising rewrite (contained latch) ────────────────
|
|
858
|
+
// While a `tools/list` request id is outstanding the forwarder switches from
|
|
859
|
+
// raw passthrough to buffer-and-rewrite, injecting a `goal` property into the
|
|
860
|
+
// advertised codex/codex-reply schemas of that one response, then returns to
|
|
861
|
+
// raw. Observation above stays the SOLE authority for inFlight/the watchdog;
|
|
862
|
+
// this path only changes HOW bytes reach the wire.
|
|
863
|
+
const pendingToolsListIds = new Set(); // idKey(id) of outstanding tools/list requests (the latch)
|
|
864
|
+
let rewriteBuf = Buffer.alloc(0); // buffer-mode accumulator; holds ≤1 trailing partial after a flush
|
|
865
|
+
let rewriteSkipUntilNewline = false; // forwarding raw to the next newline (oversized frame or mode-boundary align)
|
|
866
|
+
let rewriteSkipReleaseId; // idKey to release when the skipped frame's newline lands (oversized response only)
|
|
867
|
+
let oversizedToolsListLogged = false; // log the first rewrite-cap drop only
|
|
868
|
+
|
|
815
869
|
const killGroup = (signal) => {
|
|
816
870
|
try {
|
|
817
871
|
if (child.pid) process.kill(-child.pid, signal);
|
|
@@ -996,10 +1050,53 @@ function runCodexPassthrough({
|
|
|
996
1050
|
killGroup("SIGKILL");
|
|
997
1051
|
|
|
998
1052
|
if (emit && hasEmittableInFlight()) {
|
|
999
|
-
// Framing recovery
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1053
|
+
// Framing recovery. Precedence handles bytes WITHHELD by buffer mode (which
|
|
1054
|
+
// the plain stdoutObsBuf recovery would mis-handle). EVERY write here is
|
|
1055
|
+
// try/catch-guarded: finalize runs synchronously from close/exit/idle/signal
|
|
1056
|
+
// handlers, so an unguarded EPIPE would escape into uncaughtException ->
|
|
1057
|
+
// fatalShutdown -> a re-entrant finalize early-return, skipping
|
|
1058
|
+
// flushThenExit/process.exit and hanging the wrapper.
|
|
1059
|
+
if (rewriteSkipUntilNewline) {
|
|
1060
|
+
// Oversized/align mid-skip: head already forwarded raw, remainder
|
|
1061
|
+
// unrecoverable. Discard; the -32001 loop covers the still-open id.
|
|
1062
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1063
|
+
rewriteSkipUntilNewline = false;
|
|
1064
|
+
stdoutObsBuf = Buffer.alloc(0);
|
|
1065
|
+
if (!lastForwardedByteWasNewline) {
|
|
1066
|
+
try { process.stdout.write("\n"); } catch {}
|
|
1067
|
+
lastForwardedByteWasNewline = true;
|
|
1068
|
+
}
|
|
1069
|
+
} else if (rewriteBuf.length > 0) {
|
|
1070
|
+
// A withheld buffered partial (never forwarded). If it parses as a COMPLETE
|
|
1071
|
+
// message (only its trailing newline missing) — possible only when the whole
|
|
1072
|
+
// frame arrived post-latch, so NONE of it is on the wire — deliver it
|
|
1073
|
+
// (rewritten if a pending tools/list response, else raw) + "\n" and clear its
|
|
1074
|
+
// id (no -32001). Otherwise (a mode-boundary tail — pre-empted by the
|
|
1075
|
+
// align-skip — or codex died mid-frame) discard; the -32001 loop covers it.
|
|
1076
|
+
const frameStr = rewriteBuf.toString("utf8");
|
|
1077
|
+
let outStr = null;
|
|
1078
|
+
try {
|
|
1079
|
+
const m = JSON.parse(frameStr);
|
|
1080
|
+
outStr = frameStr;
|
|
1081
|
+
if (
|
|
1082
|
+
m && typeof m === "object" && "id" in m &&
|
|
1083
|
+
("result" in m || "error" in m) &&
|
|
1084
|
+
pendingToolsListIds.has(idKey(m.id)) && injectGoalIntoToolsListMessage(m)
|
|
1085
|
+
) {
|
|
1086
|
+
outStr = JSON.stringify(m);
|
|
1087
|
+
}
|
|
1088
|
+
} catch { outStr = null; }
|
|
1089
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1090
|
+
stdoutObsBuf = Buffer.alloc(0);
|
|
1091
|
+
if (outStr !== null) {
|
|
1092
|
+
try { process.stdout.write(`${outStr}\n`); } catch {}
|
|
1093
|
+
observeOutgoingLine(frameStr); // clear its id -> no synthetic error for it
|
|
1094
|
+
lastForwardedByteWasNewline = true;
|
|
1095
|
+
} else if (!lastForwardedByteWasNewline) {
|
|
1096
|
+
try { process.stdout.write("\n"); } catch {}
|
|
1097
|
+
lastForwardedByteWasNewline = true;
|
|
1098
|
+
}
|
|
1099
|
+
} else if (stdoutObsBuf.length > 0) {
|
|
1003
1100
|
observeOutgoingLine(stdoutObsBuf.toString("utf8"));
|
|
1004
1101
|
stdoutObsBuf = Buffer.alloc(0);
|
|
1005
1102
|
try { process.stdout.write("\n"); } catch {}
|
|
@@ -1026,6 +1123,12 @@ function runCodexPassthrough({
|
|
|
1026
1123
|
}
|
|
1027
1124
|
}
|
|
1028
1125
|
|
|
1126
|
+
// Hygiene: drop the rewrite latch/skip state (forwarding has stopped).
|
|
1127
|
+
pendingToolsListIds.clear();
|
|
1128
|
+
rewriteSkipUntilNewline = false;
|
|
1129
|
+
rewriteSkipReleaseId = undefined;
|
|
1130
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1131
|
+
|
|
1029
1132
|
flushThenExit(exitCode);
|
|
1030
1133
|
};
|
|
1031
1134
|
|
|
@@ -1065,35 +1168,153 @@ function runCodexPassthrough({
|
|
|
1065
1168
|
logErr(`[codex] ${chunk.toString().trimEnd()}`);
|
|
1066
1169
|
});
|
|
1067
1170
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1171
|
+
const logRewriteDropOnce = () => {
|
|
1172
|
+
if (!oversizedToolsListLogged) {
|
|
1173
|
+
logErr(
|
|
1174
|
+
"[mcp-agents] codex passthrough: tools/list-window frame exceeded rewrite cap; " +
|
|
1175
|
+
"forwarding raw (goal not advertised on this response)",
|
|
1176
|
+
);
|
|
1177
|
+
oversizedToolsListLogged = true;
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
// Raw forward of one buffer plus the existing first-`!ok` backpressure handling
|
|
1182
|
+
// (pause codex + suspend the watchdog until drain). Returns the write result.
|
|
1183
|
+
// Used by BOTH the raw fast path and buffer mode, so the wire-state tracking and
|
|
1184
|
+
// backpressure contract live in exactly one place.
|
|
1185
|
+
const forwardChunk = (buf) => {
|
|
1186
|
+
if (buf.length === 0) return true;
|
|
1187
|
+
lastForwardedByteWasNewline = buf[buf.length - 1] === NEWLINE;
|
|
1188
|
+
const ok = process.stdout.write(buf);
|
|
1189
|
+
if (!ok && !stdoutPaused) {
|
|
1190
|
+
// Downstream full: pause codex and suspend the idle watchdog until the
|
|
1191
|
+
// client drains, so a slow reader is never mistaken for a stalled codex.
|
|
1192
|
+
stdoutPaused = true;
|
|
1193
|
+
clearIdle();
|
|
1194
|
+
child.stdout.pause();
|
|
1195
|
+
}
|
|
1196
|
+
return ok;
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
// Once no tools/list id is outstanding (and not mid-skip), a trailing partial in
|
|
1200
|
+
// rewriteBuf is a NON-tools/list frame (no response expected), so it must not stay
|
|
1201
|
+
// withheld in buffer mode — raw mode forwards partials as they arrive, and
|
|
1202
|
+
// withholding it would byte-lose it if codex dies before its newline. Forward it
|
|
1203
|
+
// raw and drop back to the fast path. Called from BOTH paths that can clear the
|
|
1204
|
+
// latch: the end of flushRewriteBuf (a response completed) and noteInbound's
|
|
1205
|
+
// cancel branch (a tools/list was canceled on stdin, which never runs the flush).
|
|
1206
|
+
const returnToRawIfLatchClear = () => {
|
|
1207
|
+
if (
|
|
1208
|
+
!finalizing && pendingToolsListIds.size === 0 &&
|
|
1209
|
+
!rewriteSkipUntilNewline && rewriteBuf.length > 0
|
|
1210
|
+
) {
|
|
1211
|
+
forwardChunk(rewriteBuf);
|
|
1212
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// Flush every COMPLETE frame from rewriteBuf, rewriting only the matched
|
|
1217
|
+
// tools/list response and forwarding everything else byte-for-byte. NEVER
|
|
1218
|
+
// early-returns on backpressure: forwardChunk pauses codex on the first `!ok`,
|
|
1219
|
+
// but this chunk's frames are all queued (Node buffers regardless), so no
|
|
1220
|
+
// COMPLETE frame is ever stranded — exactly today's "one write(chunk), then
|
|
1221
|
+
// pause the source" semantics. After this returns rewriteBuf holds at most one
|
|
1222
|
+
// trailing INCOMPLETE partial.
|
|
1223
|
+
const flushRewriteBuf = () => {
|
|
1224
|
+
if (rewriteSkipUntilNewline) {
|
|
1225
|
+
const nl = rewriteBuf.indexOf(NEWLINE);
|
|
1226
|
+
if (nl === -1) {
|
|
1227
|
+
// Still inside the skipped/aligned frame: forward it all raw, stay skipping.
|
|
1228
|
+
forwardChunk(rewriteBuf);
|
|
1229
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
forwardChunk(rewriteBuf.subarray(0, nl + 1)); // forward through the newline raw
|
|
1233
|
+
rewriteBuf = rewriteBuf.subarray(nl + 1);
|
|
1234
|
+
if (rewriteSkipReleaseId !== undefined) {
|
|
1235
|
+
pendingToolsListIds.delete(rewriteSkipReleaseId);
|
|
1236
|
+
rewriteSkipReleaseId = undefined;
|
|
1237
|
+
}
|
|
1238
|
+
rewriteSkipUntilNewline = false;
|
|
1239
|
+
}
|
|
1240
|
+
let nl;
|
|
1241
|
+
while ((nl = rewriteBuf.indexOf(NEWLINE)) !== -1) {
|
|
1242
|
+
const frameBytes = rewriteBuf.subarray(0, nl + 1); // original bytes incl. delimiter
|
|
1243
|
+
rewriteBuf = rewriteBuf.subarray(nl + 1); // consume-first: never re-forward, never wedge
|
|
1244
|
+
if (nl > MAX_BUFFER_BYTES) {
|
|
1245
|
+
// Complete frame larger than the cap: forward raw without parsing (mirrors
|
|
1246
|
+
// observeOutgoing's oversized branch), releasing only a matching pending id.
|
|
1247
|
+
logRewriteDropOnce();
|
|
1248
|
+
const pid = peekResponseId(frameBytes);
|
|
1249
|
+
if (pid !== undefined && pendingToolsListIds.has(idKey(pid))) {
|
|
1250
|
+
pendingToolsListIds.delete(idKey(pid));
|
|
1251
|
+
}
|
|
1252
|
+
forwardChunk(frameBytes);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
let outBuf = frameBytes; // default: byte-for-byte
|
|
1256
|
+
try {
|
|
1257
|
+
const msg = JSON.parse(
|
|
1258
|
+
frameBytes.subarray(0, frameBytes.length - 1).toString("utf8"),
|
|
1259
|
+
);
|
|
1260
|
+
if (
|
|
1261
|
+
msg && typeof msg === "object" && "id" in msg &&
|
|
1262
|
+
("result" in msg || "error" in msg) &&
|
|
1263
|
+
pendingToolsListIds.has(idKey(msg.id))
|
|
1264
|
+
) {
|
|
1265
|
+
pendingToolsListIds.delete(idKey(msg.id));
|
|
1266
|
+
if (injectGoalIntoToolsListMessage(msg)) {
|
|
1267
|
+
outBuf = Buffer.from(`${JSON.stringify(msg)}\n`, "utf8");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
} catch {
|
|
1271
|
+
outBuf = frameBytes; // unparseable (mode-boundary tail / partial) — forward original bytes
|
|
1272
|
+
}
|
|
1273
|
+
forwardChunk(outBuf);
|
|
1274
|
+
}
|
|
1275
|
+
if (rewriteBuf.length > MAX_BUFFER_BYTES) {
|
|
1276
|
+
// Partial frame already past the cap with no newline: abandon rewriting for
|
|
1277
|
+
// THIS frame, forward what we have raw, and skip to its newline. Release only
|
|
1278
|
+
// a matching id, deferred to that newline.
|
|
1279
|
+
logRewriteDropOnce();
|
|
1280
|
+
const pid = peekResponseId(rewriteBuf);
|
|
1281
|
+
rewriteSkipReleaseId =
|
|
1282
|
+
pid !== undefined && pendingToolsListIds.has(idKey(pid)) ? idKey(pid) : undefined;
|
|
1283
|
+
forwardChunk(rewriteBuf);
|
|
1284
|
+
rewriteBuf = Buffer.alloc(0);
|
|
1285
|
+
rewriteSkipUntilNewline = true;
|
|
1286
|
+
}
|
|
1287
|
+
// Latch boundary: a response just completed may have emptied the latch — if so,
|
|
1288
|
+
// flush any trailing NON-tools/list partial raw and return to the fast path.
|
|
1289
|
+
returnToRawIfLatchClear();
|
|
1290
|
+
};
|
|
1291
|
+
const bufferModeForward = (chunk) => {
|
|
1292
|
+
rewriteBuf = rewriteBuf.length ? Buffer.concat([rewriteBuf, chunk]) : chunk;
|
|
1293
|
+
flushRewriteBuf();
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
// Forward codex stdout to the client. Steady state is a byte-for-byte raw
|
|
1297
|
+
// passthrough (forwardChunk); while a tools/list response is pending the
|
|
1298
|
+
// forwarder buffers and rewrites that one frame (bufferModeForward) to advertise
|
|
1299
|
+
// `goal`. Observation runs on the ORIGINAL bytes and stays the sole authority for
|
|
1300
|
+
// clearing in-flight ids — by the time it runs, every complete frame in this
|
|
1301
|
+
// chunk was already forwarded/queued, so it never leads forwarding.
|
|
1072
1302
|
child.stdout.on("data", (chunk) => {
|
|
1073
1303
|
if (finalizing) return; // stream ownership has been taken over
|
|
1074
|
-
resetIdle();
|
|
1304
|
+
resetIdle(); // UNCONDITIONAL, before the mode branch — buffer-mode activity must keep the watchdog alive
|
|
1075
1305
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1306
|
+
if (pendingToolsListIds.size > 0 || rewriteBuf.length > 0 || rewriteSkipUntilNewline) {
|
|
1307
|
+
bufferModeForward(chunk);
|
|
1308
|
+
} else {
|
|
1309
|
+
forwardChunk(chunk);
|
|
1080
1310
|
}
|
|
1081
|
-
|
|
1311
|
+
|
|
1082
1312
|
try {
|
|
1083
1313
|
observeOutgoing(chunk); // bounded parse-for-ids; never alters forwarded bytes
|
|
1084
1314
|
} catch (err) {
|
|
1085
1315
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1086
1316
|
logErr(`[mcp-agents] codex passthrough: stdout observation error (ignored): ${msg}`);
|
|
1087
1317
|
}
|
|
1088
|
-
if (!ok) {
|
|
1089
|
-
// Downstream full: pause codex and suspend the idle watchdog until the
|
|
1090
|
-
// client drains, so a slow reader is never mistaken for a stalled codex.
|
|
1091
|
-
// Trade-off: a client that never drains keeps the request open with no
|
|
1092
|
-
// watchdog — but a synthetic error could not be delivered to it anyway.
|
|
1093
|
-
stdoutPaused = true;
|
|
1094
|
-
clearIdle();
|
|
1095
|
-
child.stdout.pause();
|
|
1096
|
-
}
|
|
1097
1318
|
});
|
|
1098
1319
|
|
|
1099
1320
|
process.stdout.on("drain", () => {
|
|
@@ -1130,7 +1351,16 @@ function runCodexPassthrough({
|
|
|
1130
1351
|
// so even an elicitation response — bare id, no method — keeps a healthy
|
|
1131
1352
|
// interactive flow alive.)
|
|
1132
1353
|
if (msg.method === "notifications/cancelled") {
|
|
1133
|
-
|
|
1354
|
+
const rid = msg.params?.requestId;
|
|
1355
|
+
cancelInFlight(rid);
|
|
1356
|
+
// A canceled/never-answered tools/list must not wedge buffer mode open. If
|
|
1357
|
+
// this cancel cleared the last pending tools/list id while a NON-tools/list
|
|
1358
|
+
// partial is withheld in rewriteBuf, flush it raw — otherwise a codex exit
|
|
1359
|
+
// with only-canceled work would drop those bytes (finalize skips recovery).
|
|
1360
|
+
if (rid != null) {
|
|
1361
|
+
pendingToolsListIds.delete(idKey(rid));
|
|
1362
|
+
returnToRawIfLatchClear();
|
|
1363
|
+
}
|
|
1134
1364
|
return;
|
|
1135
1365
|
}
|
|
1136
1366
|
// A client message awaits a response iff it carries BOTH an id and a method.
|
|
@@ -1138,6 +1368,22 @@ function runCodexPassthrough({
|
|
|
1138
1368
|
// for in-flight tracking.
|
|
1139
1369
|
if (msg.id != null && typeof msg.method === "string") {
|
|
1140
1370
|
addInFlight(msg.id);
|
|
1371
|
+
if (msg.method === "tools/list") {
|
|
1372
|
+
// Arm the goal-advertising rewrite latch for this tools/list response. If
|
|
1373
|
+
// buffer mode would START mid-frame (a pre-latch frame's head was already
|
|
1374
|
+
// raw-forwarded and its newline hasn't arrived), first align by raw-skipping
|
|
1375
|
+
// the orphan tail to its next newline — so the tail is forwarded
|
|
1376
|
+
// byte-for-byte and never mis-parsed as a standalone frame nor byte-lost at
|
|
1377
|
+
// finalize. Equivalent to today's raw behaviour for that straddled frame.
|
|
1378
|
+
if (
|
|
1379
|
+
pendingToolsListIds.size === 0 && rewriteBuf.length === 0 &&
|
|
1380
|
+
!rewriteSkipUntilNewline && !lastForwardedByteWasNewline
|
|
1381
|
+
) {
|
|
1382
|
+
rewriteSkipUntilNewline = true;
|
|
1383
|
+
rewriteSkipReleaseId = undefined;
|
|
1384
|
+
}
|
|
1385
|
+
pendingToolsListIds.add(idKey(msg.id));
|
|
1386
|
+
}
|
|
1141
1387
|
}
|
|
1142
1388
|
};
|
|
1143
1389
|
|