Skip to content

Commit 16af90b

Browse files
xuiocodex
andcommitted
Harden Claude Codex session reliability
Add progress heartbeats for long blocking MCP calls, preserve persistent session project directories across follow-up prompts, and make the Claude live harnesses deterministic with explicit plugin loading. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent d6d0720 commit 16af90b

15 files changed

Lines changed: 556 additions & 94 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ node_modules/
55
coverage/
66
.vitest/
77
tmp/
8+
.in_use/

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Optional environment overrides:
3333
- `CODEX_SUBAGENTS_JOB_TTL_SECONDS`: completed async job retention window. Defaults to one hour.
3434
- `CODEX_SUBAGENTS_LOG_LEVEL`: `debug`, `info`, `warn`, `error`, or `silent`. Defaults to `debug`.
3535
- `CODEX_SUBAGENTS_LOG_MAX_STRING_CHARS`: maximum string payload retained per log field before truncation metadata is used. Defaults to `20000`.
36+
- `CODEX_SUBAGENTS_PROGRESS_HEARTBEAT_MS`: interval for progress heartbeats on blocking tool calls. Defaults to `10000`.
3637

3738
## Spark And Nested Subagents
3839

@@ -130,7 +131,9 @@ npm run test:claude-desktop
130131

131132
`test:claude-real-codex` is the full opt-in live path: Claude Code loads the plugin and calls real Codex through the desktop app binary, including one single agent, one parallel run, and one nested Spark subagent run. It spends both Claude and Codex tokens, so it is intentionally not part of the default suite.
132133

133-
`test:claude-autodiscovery` is an opt-in live Claude Code test for automatic tool selection. It gives Claude a natural "ask Codex" request, uses the installed plugin and fake Codex binary, and verifies that Claude chooses the Codex MCP tool without being told the exact tool name.
134+
`test:claude-real-session` is an opt-in live Claude Code test for daemonless persistent sessions. It loads the symlinked installed plugin, starts a real Codex session, sends a follow-up without `project_dir`, and verifies the session stays pinned to the original project directory.
135+
136+
`test:claude-autodiscovery` is an opt-in live Claude Code test for automatic tool selection. It gives Claude a natural "ask Codex" request, loads the local plugin with the fake Codex binary, and verifies that Claude chooses the Codex MCP tool without being told the exact tool name.
134137

135138
Run Claude Code with the local plugin:
136139

@@ -172,7 +175,7 @@ Prefer `start_agent_run` or `start_agents_run` for work that may run longer than
172175

173176
Async job snapshots expose partial stdout/stderr and parsed event summaries through `get_agent_run` while work is still running.
174177

175-
When a client supports MCP progress tokens, `run_agent`, `run_agents`, `start_agent_run`, `start_agents_run`, `get_agent_run`, `wait_agent_run`, and `cancel_agent_run` send progress notifications. SDK clients should pass an `onprogress` handler and enable timeout reset on progress for long waits.
178+
When a client supports MCP progress tokens, `run_agent`, `run_agents`, `run_agents_aggregate`, `start_session`, `send_session_prompt`, `start_agent_run`, `start_agents_run`, `get_agent_run`, `wait_agent_run`, and `cancel_agent_run` send progress notifications. SDK clients should pass an `onprogress` handler and enable timeout reset on progress for long waits.
176179

177180
## License
178181

dist/index.js

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22930,6 +22930,11 @@ function isParallelResult(value) {
2293022930
}
2293122931

2293222932
// src/sessions.ts
22933+
function withoutUndefined(value) {
22934+
return Object.fromEntries(
22935+
Object.entries(value).filter(([, child]) => child !== void 0)
22936+
);
22937+
}
2293322938
function snapshot2(session) {
2293422939
return {
2293522940
id: session.id,
@@ -23006,7 +23011,7 @@ var CodexSessionManager = class {
2300623011
});
2300723012
const result = await this.runTurn(session, {
2300823013
...session.baseOptions,
23009-
...overrides,
23014+
...withoutUndefined(overrides),
2301023015
prompt,
2301123016
resumeSessionId: session.codexThreadId,
2301223017
ephemeral: false
@@ -23064,6 +23069,12 @@ var CodexSessionManager = class {
2306423069
session.lastResult = result;
2306523070
session.codexThreadId = result.eventSummary.threadId ?? session.codexThreadId;
2306623071
session.projectDir = result.cwd;
23072+
session.cwd = result.cwd;
23073+
session.baseOptions = {
23074+
...session.baseOptions,
23075+
projectDir: result.cwd,
23076+
cwd: void 0
23077+
};
2306723078
session.status = result.ok ? "active" : result.status === "cancelled" ? "cancelled" : "failed";
2306823079
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2306923080
logger.rawInfo("session.turn.finish", {
@@ -23347,6 +23358,22 @@ async function reportAgentResult(progress, result) {
2334723358
const status = result.status ?? (result.ok ? "completed" : "failed");
2334823359
await progress.send(result.ok ? "Codex run completed" : `Codex run ${status}`);
2334923360
}
23361+
function progressHeartbeatMs() {
23362+
const parsed = Number(process.env.CODEX_SUBAGENTS_PROGRESS_HEARTBEAT_MS);
23363+
if (!Number.isFinite(parsed) || parsed <= 0) return 1e4;
23364+
return Math.max(25, Math.min(Math.floor(parsed), 6e4));
23365+
}
23366+
async function withProgressHeartbeat(progress, message, operation) {
23367+
const interval = setInterval(() => {
23368+
void progress.send(message);
23369+
}, progressHeartbeatMs());
23370+
interval.unref();
23371+
try {
23372+
return await operation();
23373+
} finally {
23374+
clearInterval(interval);
23375+
}
23376+
}
2335023377
function toRunOptions(args) {
2335123378
return {
2335223379
prompt: args.prompt,
@@ -23505,11 +23532,15 @@ server.registerTool(
2350523532
const progress = createProgressReporter(extra);
2350623533
try {
2350723534
await progress.send("Queued Codex run");
23508-
const result = await runQueuedAgent(toRunOptions(args), {
23509-
onStart: (queuedMs) => {
23510-
void progress.send(`Started Codex run after ${queuedMs}ms queued`);
23511-
}
23512-
});
23535+
const result = await withProgressHeartbeat(
23536+
progress,
23537+
"Still running Codex run",
23538+
() => runQueuedAgent(toRunOptions(args), {
23539+
onStart: (queuedMs) => {
23540+
void progress.send(`Started Codex run after ${queuedMs}ms queued`);
23541+
}
23542+
})
23543+
);
2351323544
await reportAgentResult(progress, result);
2351423545
await progress.flush();
2351523546
return jsonResult({ agent: compactAgentResultForMcp(result) }, !result.ok);
@@ -23611,18 +23642,22 @@ server.registerTool(
2361123642
let completed = 0;
2361223643
let failed = 0;
2361323644
await progress.send(`Queued ${args.agents.length} Codex agents`, { total });
23614-
const results = await runQueuedAgents(toParallelRunOptions(args), {
23615-
onStart: (queuedMs, label) => {
23616-
void progress.send(`Started ${label ?? "Codex agent"} after ${queuedMs}ms queued`, { total });
23617-
},
23618-
onComplete: async (result) => {
23619-
completed += 1;
23620-
if (!result.ok) failed += 1;
23621-
const last = completed === args.agents.length;
23622-
const message = last ? failed === 0 ? `Parallel Codex run completed (${completed}/${args.agents.length})` : `Parallel Codex run finished with errors (${completed}/${args.agents.length})` : `${result.ok ? "Completed" : "Finished"} ${result.name ?? "Codex agent"} (${completed}/${args.agents.length})`;
23623-
await progress.send(message, last ? { progress: total, total } : { total });
23624-
}
23625-
});
23645+
const results = await withProgressHeartbeat(
23646+
progress,
23647+
`Still running ${args.agents.length} Codex agents`,
23648+
() => runQueuedAgents(toParallelRunOptions(args), {
23649+
onStart: (queuedMs, label) => {
23650+
void progress.send(`Started ${label ?? "Codex agent"} after ${queuedMs}ms queued`, { total });
23651+
},
23652+
onComplete: async (result) => {
23653+
completed += 1;
23654+
if (!result.ok) failed += 1;
23655+
const last = completed === args.agents.length;
23656+
const message = last ? failed === 0 ? `Parallel Codex run completed (${completed}/${args.agents.length})` : `Parallel Codex run finished with errors (${completed}/${args.agents.length})` : `${result.ok ? "Completed" : "Finished"} ${result.name ?? "Codex agent"} (${completed}/${args.agents.length})`;
23657+
await progress.send(message, last ? { progress: total, total } : { total });
23658+
}
23659+
})
23660+
);
2362623661
const ok = results.every((result) => result.ok);
2362723662
await progress.flush();
2362823663
return jsonResult(
@@ -23663,19 +23698,23 @@ server.registerTool(
2366323698
const total = args.agents.length * 2 + 1;
2366423699
let completed = 0;
2366523700
await progress.send(`Queued ${args.agents.length} Codex agents for aggregation`, { total });
23666-
const results = await runQueuedAgents(toParallelRunOptions(args), {
23667-
onStart: (queuedMs, label) => {
23668-
void progress.send(`Started ${label ?? "Codex agent"} after ${queuedMs}ms queued`, { total });
23669-
},
23670-
onComplete: async () => {
23671-
completed += 1;
23672-
const last = completed === args.agents.length;
23673-
await progress.send(
23674-
last ? `Aggregating ${completed}/${args.agents.length} Codex results` : `Completed ${completed}/${args.agents.length} Codex agents`,
23675-
last ? { progress: total, total } : { total }
23676-
);
23677-
}
23678-
});
23701+
const results = await withProgressHeartbeat(
23702+
progress,
23703+
`Still running ${args.agents.length} Codex agents for aggregation`,
23704+
() => runQueuedAgents(toParallelRunOptions(args), {
23705+
onStart: (queuedMs, label) => {
23706+
void progress.send(`Started ${label ?? "Codex agent"} after ${queuedMs}ms queued`, { total });
23707+
},
23708+
onComplete: async () => {
23709+
completed += 1;
23710+
const last = completed === args.agents.length;
23711+
await progress.send(
23712+
last ? `Aggregating ${completed}/${args.agents.length} Codex results` : `Completed ${completed}/${args.agents.length} Codex agents`,
23713+
last ? { progress: total, total } : { total }
23714+
);
23715+
}
23716+
})
23717+
);
2367923718
const aggregation = aggregateAgentResults(results);
2368023719
await progress.flush();
2368123720
return jsonResult(
@@ -23820,12 +23859,16 @@ server.registerTool(
2382023859
const progress = createProgressReporter(extra);
2382123860
try {
2382223861
await progress.send("Starting persistent Codex session");
23823-
const { session, result } = await sessionManager.start(
23824-
{
23825-
...toRunOptions(args),
23826-
ephemeral: false
23827-
},
23828-
{ sessionName: args.session_name }
23862+
const { session, result } = await withProgressHeartbeat(
23863+
progress,
23864+
"Still starting persistent Codex session",
23865+
() => sessionManager.start(
23866+
{
23867+
...toRunOptions(args),
23868+
ephemeral: false
23869+
},
23870+
{ sessionName: args.session_name }
23871+
)
2382923872
);
2383023873
await reportAgentResult(progress, result);
2383123874
await progress.flush();
@@ -23857,7 +23900,11 @@ server.registerTool(
2385723900
const progress = createProgressReporter(extra);
2385823901
try {
2385923902
await progress.send(`Resuming Codex session ${args.session_id}`);
23860-
const { session, result, error: error2 } = await sessionManager.send(args.session_id, args.prompt, toRunOptions(args));
23903+
const { session, result, error: error2 } = await withProgressHeartbeat(
23904+
progress,
23905+
`Still running Codex session ${args.session_id}`,
23906+
() => sessionManager.send(args.session_id, args.prompt, toRunOptions(args))
23907+
);
2386123908
if (error2 || !session || !result) {
2386223909
await progress.flush();
2386323910
return jsonResult(

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"test:claude-autodiscovery": "node test/claude-autodiscovery.mjs",
3535
"test:claude-large-output": "node test/claude-large-output.mjs",
3636
"test:claude-real-codex": "node test/claude-real-codex.mjs",
37+
"test:claude-real-session": "node test/claude-real-session.mjs",
3738
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:dev-link",
3839
"test:comprehensive": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:codex-runtime && npm run validate:plugin && npm run test:claude-desktop",
3940
"test:claude-desktop": "node test/claude-desktop-cli.mjs"

scripts/link-claude-dev-plugin.mjs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
1+
import { lstat, mkdir, readFile, realpath, rename, symlink, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { fileURLToPath } from "node:url";
@@ -91,9 +91,6 @@ const marketplaceLink = await replaceWithSymlink(marketplacePluginPath, root);
9191
const cacheLink = await replaceWithSymlink(installPath, root);
9292
const entry = await ensureInstalledPluginsEntry();
9393

94-
// Remove stale Claude marker files that can be left behind when replacing cache copies.
95-
await rm(path.join(installPath, ".in_use"), { force: true });
96-
9794
console.log(`Marketplace plugin path: ${marketplaceLink.path} -> ${root}`);
9895
console.log(`Installed plugin path: ${cacheLink.path} -> ${root}`);
9996
console.log(`Installed plugin entry: ${entry.installPath}`);

0 commit comments

Comments
 (0)