Skip to content

Commit ea59713

Browse files
xuiocodex
andcommitted
Bound blocking Codex waits
Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 39c116b commit ea59713

8 files changed

Lines changed: 456 additions & 46 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ to `codex://sessions/{session_id}` for milestone and completion updates. For a
120120
completed first turn, Claude should set `keep_session: true`; for long first
121121
turns, Claude should set `background: true`.
122122

123+
Blocking waits are capped to responsive slices by default. When `codex_followup`
124+
or `codex_wait_any` returns `completed: false`, the Codex session is still
125+
running; call the wait tool again or read `codex://sessions/{session_id}`.
126+
123127
When app-server sessions use the Codex desktop binary, they are recorded as
124128
normal top-level Codex threads rather than hidden daemon work. The plugin sets the
125129
thread name from Claude's task label when the installed Codex app-server supports

dist/index.js

Lines changed: 143 additions & 18 deletions
Large diffs are not rendered by default.

docs/USAGE.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Claude Code development workflow.
1515
| Model | Codex account or config default unless a tool call supplies one |
1616
| Reasoning effort | `medium` when a default is needed |
1717
| Logging | verbose JSONL on stderr |
18+
| Blocking wait cap | `300000` ms per wait call |
1819

1920
Claude should pass `project_dir` when Codex should inspect the same repository or
2021
subdirectory that Claude is working in. If omitted, the server uses
@@ -78,6 +79,11 @@ Use this decision path when writing prompts or debugging Claude tool choice:
7879
supports thread archiving, the plugin best-effort archives that Desktop thread so
7980
stopped Claude subagent work does not keep cluttering the active thread list.
8081

82+
Blocking wait tools return periodically even when the requested timeout is very
83+
large. If `completed` is false with `timeoutReason: "wait_timeout"`, the Codex
84+
session is still managed by the MCP server; call `codex_followup` mode `wait` or
85+
`codex_wait_any` again, or read `codex://sessions/{session_id}` for live state.
86+
8187
When in doubt, read `codex://usage` and then choose among the native front-door tools.
8288

8389
## When To Prefer Codex
@@ -222,11 +228,14 @@ To collect whichever background session finishes first:
222228
```json
223229
{
224230
"session_ids": ["session-a", "session-b"],
225-
"wait_timeout_ms": 600000
231+
"wait_timeout_ms": 300000
226232
}
227233
```
228234

229-
Use the returned `remaining_session_ids` in the next `codex_wait_any` call.
235+
Use the returned `remaining_session_ids` in the next `codex_wait_any` call. Long
236+
requested waits are capped by `CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS` so Claude
237+
does not sit in one MCP call long enough for Desktop's inactivity watchdog to
238+
kill the parent session.
230239

231240
To stop a background or actively running session:
232241

@@ -339,6 +348,7 @@ servers should not be loaded for the run.
339348
| `CODEX_SUBAGENTS_MAX_SESSIONS` | Maximum retained persistent sessions |
340349
| `CODEX_SUBAGENTS_MAX_SESSION_QUEUED_TURNS` | Maximum queued turns per session |
341350
| `CODEX_SUBAGENTS_MAX_SESSION_MILESTONES` | Recent milestone ring size per session, clamped to 10-500 |
351+
| `CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS` | Maximum duration for one blocking wait call, clamped to 25-300000 ms |
342352
| `CODEX_SUBAGENTS_SESSION_COMPLETED_TTL_SECONDS` | Retention for failed/cancelled sessions |
343353
| `CODEX_SUBAGENTS_SESSION_IDLE_TTL_SECONDS` | Retention for idle resumable sessions |
344354
| `CODEX_SUBAGENTS_SESSION_STATE_FILE` | Durable session metadata path |

docs/wiki/Tool-Guide.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ codebases, server/deployment work, difficult debugging, or adversarial security
1818
and correctness review. Prefer native Task when the work depends on Claude's
1919
conversation history or Claude-only built-in tools.
2020

21+
Blocking waits return in bounded slices so Claude Desktop stays responsive. If
22+
`completed` is false, call `codex_followup` mode `wait` or `codex_wait_any`
23+
again, or read `codex://sessions/{session_id}`.
24+
2125
## One Agent
2226

2327
```text

src/index.ts

Lines changed: 143 additions & 23 deletions
Large diffs are not rendered by default.

src/wait-timeout.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const defaultBlockingWaitTimeoutMs = 300_000;
2+
export const hardMaxBlockingWaitTimeoutMs = 300_000;
3+
export const minBlockingWaitTimeoutMs = 25;
4+
5+
export type BlockingWaitTimeout = {
6+
requestedMs: number;
7+
effectiveMs: number;
8+
capped: boolean;
9+
};
10+
11+
export function configuredMaxBlockingWaitMs(env: NodeJS.ProcessEnv = process.env): number {
12+
const parsed = Number(env.CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS);
13+
if (!Number.isFinite(parsed) || parsed <= 0) return defaultBlockingWaitTimeoutMs;
14+
return Math.max(minBlockingWaitTimeoutMs, Math.min(Math.floor(parsed), hardMaxBlockingWaitTimeoutMs));
15+
}
16+
17+
export function capBlockingWaitTimeout(
18+
requestedMs: number | undefined,
19+
env: NodeJS.ProcessEnv = process.env,
20+
): BlockingWaitTimeout {
21+
const requested = requestedMs ?? configuredMaxBlockingWaitMs(env);
22+
const normalized = Math.max(1, Math.floor(requested));
23+
const effective = Math.min(normalized, configuredMaxBlockingWaitMs(env));
24+
return {
25+
requestedMs: normalized,
26+
effectiveMs: effective,
27+
capped: effective < normalized,
28+
};
29+
}

test/smoke-mcp.mjs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ function assert(condition, message, details) {
3434
}
3535
}
3636

37-
async function callTool(name, args) {
38-
return client.callTool(
37+
async function callToolOn(targetClient, name, args) {
38+
return targetClient.callTool(
3939
{
4040
name,
4141
arguments: args,
@@ -44,6 +44,10 @@ async function callTool(name, args) {
4444
);
4545
}
4646

47+
async function callTool(name, args) {
48+
return callToolOn(client, name, args);
49+
}
50+
4751
async function readJsonResource(uri) {
4852
const resource = await client.readResource({ uri });
4953
return JSON.parse(resource.contents[0].text);
@@ -73,6 +77,10 @@ async function recordedCalls() {
7377
}
7478

7579
async function listToolsWithEnv(env) {
80+
return withClientEnv(env, async (debugClient) => debugClient.listTools());
81+
}
82+
83+
async function withClientEnv(env, run) {
7684
const debugClient = new Client({ name: "codex-subagents-smoke-debug", version: "0.1.0" });
7785
const debugTransport = new StdioClientTransport({
7886
command: path.join(root, "dist/index.js"),
@@ -83,7 +91,7 @@ async function listToolsWithEnv(env) {
8391
debugTransport.stderr?.resume();
8492
try {
8593
await debugClient.connect(debugTransport);
86-
return await debugClient.listTools();
94+
return await run(debugClient);
8795
} finally {
8896
await debugTransport.close().catch(() => {});
8997
}
@@ -192,6 +200,50 @@ try {
192200
missingCancel,
193201
);
194202

203+
await withClientEnv(
204+
{
205+
PATH: process.env.PATH ?? "",
206+
CODEX_SUBAGENTS_CODEX_BIN: fakeCodex,
207+
CLAUDE_PROJECT_DIR: projectDir,
208+
CODEX_SUBAGENTS_SESSION_STATE_FILE: path.join(projectDir, "capped-wait-sessions.json"),
209+
CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS: "50",
210+
FAKE_CODEX_RECORD_DIR: recordDir,
211+
},
212+
async (cappedClient) => {
213+
const slow = await callToolOn(cappedClient, "codex_task", {
214+
description: "Capped wait smoke",
215+
prompt: "capped wait smoke DELAY_MS=500",
216+
project_dir: projectDir,
217+
background: true,
218+
});
219+
assert(slow.structuredContent?.session_id, "capped wait smoke should start a background session", slow);
220+
const cappedWait = await callToolOn(cappedClient, "codex_wait_any", {
221+
session_ids: [slow.structuredContent.session_id],
222+
wait_timeout_ms: 5_000,
223+
});
224+
assert(
225+
cappedWait.structuredContent?.completed === false &&
226+
cappedWait.structuredContent?.wait_timeout_capped === true &&
227+
cappedWait.structuredContent?.effective_wait_timeout_ms === 50 &&
228+
cappedWait.structuredContent?.requested_wait_timeout_ms === 5_000,
229+
"codex_wait_any should cap long blocking waits and return a running result",
230+
cappedWait.structuredContent,
231+
);
232+
assert(
233+
typeof cappedWait.structuredContent?.elapsed_ms === "number" &&
234+
cappedWait.structuredContent.elapsed_ms < 1_000,
235+
"capped wait should return before Claude Desktop's inactivity watchdog could fire",
236+
cappedWait.structuredContent,
237+
);
238+
const cancelled = await callToolOn(cappedClient, "codex_followup", {
239+
session_id: slow.structuredContent.session_id,
240+
mode: "cancel",
241+
reason: "capped wait smoke cleanup",
242+
});
243+
assert(cancelled.structuredContent?.status === "cancelled", "capped wait smoke cleanup should cancel the session", cancelled);
244+
},
245+
);
246+
195247
const invalidReasoning = await callTool("codex_task", {
196248
description: "Invalid reasoning smoke",
197249
prompt: "should not start",
@@ -334,6 +386,29 @@ try {
334386
"codex_followup without an explicit description should not prepend boilerplate",
335387
followupCall,
336388
);
389+
const followupTimeoutSession = await callTool("codex_task", {
390+
description: "Follow-up timeout session",
391+
prompt: "follow-up timeout initial",
392+
project_dir: projectDir,
393+
keep_session: true,
394+
});
395+
const followupTimedOut = await callTool("codex_followup", {
396+
session_id: followupTimeoutSession.structuredContent.session_id,
397+
prompt: "follow-up timeout DELAY_MS=500",
398+
wait_timeout_ms: 20,
399+
});
400+
assert(
401+
followupTimedOut.structuredContent?.completed === false &&
402+
followupTimedOut.structuredContent?.timeoutReason === "wait_timeout" &&
403+
followupTimedOut.structuredContent?.effective_wait_timeout_ms === 20,
404+
"foreground codex_followup should honor wait_timeout_ms instead of waiting indefinitely",
405+
followupTimedOut.structuredContent,
406+
);
407+
await callTool("codex_followup", {
408+
session_id: followupTimeoutSession.structuredContent.session_id,
409+
mode: "cancel",
410+
reason: "follow-up timeout smoke cleanup",
411+
});
337412
const personaCall = calls.find((call) => call.method === "turn/start" && call.prompt?.includes("persona smoke"));
338413
assert(personaCall, "expected recorded persona turn/start call", calls);
339414
assert(

test/wait-timeout.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
capBlockingWaitTimeout,
4+
configuredMaxBlockingWaitMs,
5+
defaultBlockingWaitTimeoutMs,
6+
hardMaxBlockingWaitTimeoutMs,
7+
minBlockingWaitTimeoutMs,
8+
} from "../src/wait-timeout.js";
9+
10+
describe("blocking wait timeout caps", () => {
11+
it("uses a safe default below Claude Desktop's inactivity watchdog", () => {
12+
expect(configuredMaxBlockingWaitMs({} as NodeJS.ProcessEnv)).toBe(defaultBlockingWaitTimeoutMs);
13+
expect(defaultBlockingWaitTimeoutMs).toBeLessThan(1_000_000);
14+
});
15+
16+
it("caps long requested waits to the configured max", () => {
17+
expect(
18+
capBlockingWaitTimeout(10_000, {
19+
CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS: "50",
20+
} as NodeJS.ProcessEnv),
21+
).toEqual({
22+
requestedMs: 10_000,
23+
effectiveMs: 50,
24+
capped: true,
25+
});
26+
});
27+
28+
it("clamps configured max values to the hard safety ceiling", () => {
29+
expect(
30+
configuredMaxBlockingWaitMs({
31+
CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS: "9000000",
32+
} as NodeJS.ProcessEnv),
33+
).toBe(hardMaxBlockingWaitTimeoutMs);
34+
});
35+
36+
it("allows very small test caps without busy looping below the floor", () => {
37+
expect(
38+
configuredMaxBlockingWaitMs({
39+
CODEX_SUBAGENTS_MAX_BLOCKING_WAIT_MS: "1",
40+
} as NodeJS.ProcessEnv),
41+
).toBe(minBlockingWaitTimeoutMs);
42+
});
43+
});

0 commit comments

Comments
 (0)