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