Skip to content

Commit 77ae58a

Browse files
committed
Add MCP progress notifications
1 parent ccc92d7 commit 77ae58a

6 files changed

Lines changed: 412 additions & 36 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The plugin lets Claude Code launch one Codex agent or several Codex agents in pa
1616
- Prompt delivery: stdin, not command-line arguments.
1717
- Codex home: uses the user's Codex home by default; pass `isolated_codex_home: true` to use a temporary Codex home with auth but without inherited `config.toml` MCP servers.
1818
- Concurrency: Codex processes run through a global queue. Defaults are `CODEX_SUBAGENTS_MAX_GLOBAL_PROCESSES=4` and `CODEX_SUBAGENTS_MAX_PROJECT_PROCESSES=2`.
19+
- Progress: long-running tools emit MCP `notifications/progress` events when the client supplies a progress token.
1920

2021
Optional environment overrides:
2122

@@ -67,10 +68,12 @@ npm run test:claude-desktop
6768

6869
`test:ci` is the GitHub-safe suite. It uses the fake Codex binary and does not require Claude Code, the Codex desktop app, or live model credentials.
6970

70-
`test:comprehensive` runs the TypeScript build, unit tests, stdio MCP smoke test, reliability matrix, MCP stress test, Codex desktop runtime probe, Claude plugin validation, and desktop-shipped Claude Code CLI plugin/auth checks. The runtime probe validates local Codex capabilities without invoking a model.
71+
`test:comprehensive` runs the TypeScript build, unit tests, stdio MCP smoke test, reliability matrix, MCP stress test, MCP progress notification test, Codex desktop runtime probe, Claude plugin validation, and desktop-shipped Claude Code CLI plugin/auth checks. The runtime probe validates local Codex capabilities without invoking a model.
7172

7273
`test:stress` uses the fake Codex binary to exercise queued async jobs, noisy output, malformed JSONL, and truncation behavior.
7374

75+
`test:progress` verifies that SDK clients receive monotonically increasing MCP progress notifications from blocking, async start, parallel, and wait-style tool calls.
76+
7477
`test:claude-orchestration` is an opt-in live Claude Code test. It loads the plugin inside Claude Code, lets Claude call the plugin MCP tools, and uses the fake Codex binary so no Codex model tokens are spent. It is kept out of `test:comprehensive` because it does spend Claude tokens.
7578

7679
`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.
@@ -109,6 +112,8 @@ Each agent accepts model, reasoning effort, sandbox, project directory, timeout,
109112

110113
Prefer `start_agent_run` or `start_agents_run` for work that may run longer than a normal MCP request. The async job API keeps Claude responsive, supports cancellation, and avoids request failures caused by long-running Codex subprocesses.
111114

115+
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.
116+
112117
## License
113118

114119
MIT

dist/index.js

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21901,6 +21901,12 @@ function snapshot(job) {
2190121901
error: job.error
2190221902
};
2190321903
}
21904+
async function callQueueCallback(callback) {
21905+
try {
21906+
await callback();
21907+
} catch {
21908+
}
21909+
}
2190421910
var AgentRunQueue = class {
2190521911
constructor(maxGlobal = readPositiveInt(process.env.CODEX_SUBAGENTS_MAX_GLOBAL_PROCESSES, 4, 32), maxPerProject = readPositiveInt(process.env.CODEX_SUBAGENTS_MAX_PROJECT_PROCESSES, 2, 32)) {
2190621912
this.maxGlobal = maxGlobal;
@@ -21962,7 +21968,7 @@ var AgentRunQueue = class {
2196221968
const queuedMs = Date.now() - task.enqueuedAt;
2196321969
this.active += 1;
2196421970
this.projectActive.set(task.projectKey, (this.projectActive.get(task.projectKey) ?? 0) + 1);
21965-
task.onStart?.(queuedMs);
21971+
void callQueueCallback(() => task.onStart?.(queuedMs));
2196621972
Promise.resolve().then(task.run).then((value) => task.resolve({ value, queuedMs })).catch((error2) => task.reject(error2)).finally(() => {
2196721973
this.active -= 1;
2196821974
const projectCount = (this.projectActive.get(task.projectKey) ?? 1) - 1;
@@ -21986,9 +21992,10 @@ async function runQueuedAgent(options, queueOptions = {}) {
2198621992
{
2198721993
signal: controller.signal,
2198821994
projectKey: queueOptions.projectKey ?? projectKeyForOptions(options),
21989-
onStart: queueOptions.onStart
21995+
onStart: (queuedMs2) => queueOptions.onStart?.(queuedMs2, options.name)
2199021996
}
2199121997
);
21998+
await callQueueCallback(() => queueOptions.onComplete?.(value));
2199221999
return {
2199322000
...value,
2199422001
queue: { queuedMs }
@@ -22011,7 +22018,7 @@ async function runQueuedAgents(options, queueOptions = {}) {
2201122018
next += 1;
2201222019
const agent = options.agents[index];
2201322020
if (!agent) continue;
22014-
results[index] = await runQueuedAgent(
22021+
const result = await runQueuedAgent(
2201522022
{
2201622023
...options,
2201722024
...agent,
@@ -22021,8 +22028,14 @@ async function runQueuedAgents(options, queueOptions = {}) {
2202122028
prompt: agent.prompt,
2202222029
name: agent.name ?? `agent-${index + 1}`
2202322030
},
22024-
queueOptions
22031+
{
22032+
...queueOptions,
22033+
onStart: (queuedMs) => queueOptions.onStart?.(queuedMs, agent.name ?? `agent-${index + 1}`),
22034+
onComplete: void 0
22035+
}
2202522036
);
22037+
results[index] = result;
22038+
await callQueueCallback(() => queueOptions.onComplete?.(result, index, options.agents.length));
2202622039
}
2202722040
}
2202822041
await Promise.all(Array.from({ length: Math.min(maxParallel, options.agents.length) }, worker));
@@ -22273,6 +22286,40 @@ function jsonResult(value, isError = false) {
2227322286
]
2227422287
};
2227522288
}
22289+
function createProgressReporter(extra) {
22290+
const progressToken = extra?._meta?.progressToken;
22291+
let progress = 0;
22292+
let pending = Promise.resolve();
22293+
async function send(message, options = {}) {
22294+
if (progressToken === void 0 || !extra) return;
22295+
pending = pending.catch(() => {
22296+
}).then(async () => {
22297+
const requested = options.progress ?? progress + 1;
22298+
progress = Math.max(progress + 1, requested);
22299+
await extra.sendNotification({
22300+
method: "notifications/progress",
22301+
params: {
22302+
progressToken,
22303+
progress,
22304+
...options.total === void 0 ? {} : { total: options.total },
22305+
message
22306+
}
22307+
});
22308+
}).catch(() => {
22309+
});
22310+
await pending;
22311+
}
22312+
async function flush() {
22313+
if (progressToken === void 0 || !extra) return;
22314+
await pending;
22315+
await new Promise((resolve) => setTimeout(resolve, 0));
22316+
}
22317+
return { send, flush };
22318+
}
22319+
async function reportAgentResult(progress, result) {
22320+
const status = result.status ?? (result.ok ? "completed" : "failed");
22321+
await progress.send(result.ok ? "Codex run completed" : `Codex run ${status}`);
22322+
}
2227622323
function toRunOptions(args) {
2227722324
return {
2227822325
prompt: args.prompt,
@@ -22403,11 +22450,20 @@ server.registerTool(
2240322450
...commonInputSchema
2240422451
}
2240522452
},
22406-
async (args) => {
22453+
async (args, extra) => {
22454+
const progress = createProgressReporter(extra);
2240722455
try {
22408-
const result = await runQueuedAgent(toRunOptions(args));
22456+
await progress.send("Queued Codex run");
22457+
const result = await runQueuedAgent(toRunOptions(args), {
22458+
onStart: (queuedMs) => {
22459+
void progress.send(`Started Codex run after ${queuedMs}ms queued`);
22460+
}
22461+
});
22462+
await reportAgentResult(progress, result);
22463+
await progress.flush();
2240922464
return jsonResult({ agent: result }, !result.ok);
2241022465
} catch (error2) {
22466+
await progress.flush();
2241122467
return jsonResult(
2241222468
{
2241322469
error: error2 instanceof Error ? error2.message : String(error2)
@@ -22428,11 +22484,16 @@ server.registerTool(
2242822484
...commonInputSchema
2242922485
}
2243022486
},
22431-
async (args) => {
22487+
async (args, extra) => {
22488+
const progress = createProgressReporter(extra);
2243222489
try {
22490+
await progress.send("Queued asynchronous Codex run");
2243322491
const job = jobManager.startAgent(toRunOptions(args));
22492+
await progress.send(`Started Codex job ${job.id}`);
22493+
await progress.flush();
2243422494
return jsonResult({ job });
2243522495
} catch (error2) {
22496+
await progress.flush();
2243622497
return jsonResult({ error: error2 instanceof Error ? error2.message : String(error2) }, true);
2243722498
}
2243822499
}
@@ -22477,10 +22538,27 @@ server.registerTool(
2247722538
...commonInputSchema
2247822539
}
2247922540
},
22480-
async (args) => {
22541+
async (args, extra) => {
22542+
const progress = createProgressReporter(extra);
2248122543
try {
22482-
const results = await runQueuedAgents(toParallelRunOptions(args));
22544+
const total = args.agents.length * 2 + 1;
22545+
let completed = 0;
22546+
let failed = 0;
22547+
await progress.send(`Queued ${args.agents.length} Codex agents`, { total });
22548+
const results = await runQueuedAgents(toParallelRunOptions(args), {
22549+
onStart: (queuedMs, label) => {
22550+
void progress.send(`Started ${label ?? "Codex agent"} after ${queuedMs}ms queued`, { total });
22551+
},
22552+
onComplete: async (result) => {
22553+
completed += 1;
22554+
if (!result.ok) failed += 1;
22555+
const last = completed === args.agents.length;
22556+
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})`;
22557+
await progress.send(message, last ? { progress: total, total } : { total });
22558+
}
22559+
});
2248322560
const ok = results.every((result) => result.ok);
22561+
await progress.flush();
2248422562
return jsonResult(
2248522563
{
2248622564
ok,
@@ -22489,6 +22567,7 @@ server.registerTool(
2248922567
!ok
2249022568
);
2249122569
} catch (error2) {
22570+
await progress.flush();
2249222571
return jsonResult(
2249322572
{
2249422573
error: error2 instanceof Error ? error2.message : String(error2)
@@ -22509,11 +22588,16 @@ server.registerTool(
2250922588
...commonInputSchema
2251022589
}
2251122590
},
22512-
async (args) => {
22591+
async (args, extra) => {
22592+
const progress = createProgressReporter(extra);
2251322593
try {
22594+
await progress.send(`Queued asynchronous run for ${args.agents.length} Codex agents`);
2251422595
const job = jobManager.startAgents(toParallelRunOptions(args));
22596+
await progress.send(`Started Codex job ${job.id}`);
22597+
await progress.flush();
2251522598
return jsonResult({ job });
2251622599
} catch (error2) {
22600+
await progress.flush();
2251722601
return jsonResult({ error: error2 instanceof Error ? error2.message : String(error2) }, true);
2251822602
}
2251922603
}
@@ -22528,9 +22612,15 @@ server.registerTool(
2252822612
job_id: jobIdSchema
2252922613
}
2253022614
},
22531-
async (args) => {
22615+
async (args, extra) => {
22616+
const progress = createProgressReporter(extra);
22617+
await progress.send(`Checking Codex job ${args.job_id}`);
22618+
await progress.flush();
2253222619
const job = jobManager.get(args.job_id);
22533-
if (!job) return jsonResult({ error: `Unknown job_id: ${args.job_id}` }, true);
22620+
if (!job) {
22621+
await progress.flush();
22622+
return jsonResult({ error: `Unknown job_id: ${args.job_id}` }, true);
22623+
}
2253422624
return jsonResult({ job });
2253522625
}
2253622626
);
@@ -22544,9 +22634,24 @@ server.registerTool(
2254422634
timeout_ms: external_exports.number().int().positive().max(3e5).default(3e4)
2254522635
}
2254622636
},
22547-
async (args) => {
22548-
const job = await jobManager.wait(args.job_id, args.timeout_ms);
22637+
async (args, extra) => {
22638+
const progress = createProgressReporter(extra);
22639+
await progress.send(`Waiting for Codex job ${args.job_id}`);
22640+
const started = Date.now();
22641+
let job = jobManager.get(args.job_id);
22642+
while (job && !job.completedAt && Date.now() - started < args.timeout_ms) {
22643+
const remaining = args.timeout_ms - (Date.now() - started);
22644+
await new Promise((resolve) => setTimeout(resolve, Math.min(1e3, Math.max(1, remaining))));
22645+
job = jobManager.get(args.job_id);
22646+
if (job) await progress.send(`Codex job ${job.status}`);
22647+
}
22648+
if (job && !job.completedAt) {
22649+
const waitedJob = await jobManager.wait(args.job_id, 1);
22650+
job = waitedJob ?? job;
22651+
}
2254922652
if (!job) return jsonResult({ error: `Unknown job_id: ${args.job_id}` }, true);
22653+
if (job.completedAt) await progress.send(`Codex job ${job.status}`);
22654+
await progress.flush();
2255022655
return jsonResult({ job }, job.status === "failed" || job.status === "cancelled");
2255122656
}
2255222657
);
@@ -22559,7 +22664,10 @@ server.registerTool(
2255922664
job_id: jobIdSchema
2256022665
}
2256122666
},
22562-
async (args) => {
22667+
async (args, extra) => {
22668+
const progress = createProgressReporter(extra);
22669+
await progress.send(`Cancelling Codex job ${args.job_id}`);
22670+
await progress.flush();
2256322671
const job = jobManager.cancel(args.job_id);
2256422672
if (!job) return jsonResult({ error: `Unknown job_id: ${args.job_id}` }, true);
2256522673
return jsonResult({ job });

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@
2424
"smoke:mcp": "node test/smoke-mcp.mjs",
2525
"test:reliability": "node test/reliability-matrix.mjs",
2626
"test:stress": "node test/stress-mcp.mjs",
27+
"test:progress": "node test/progress-mcp.mjs",
2728
"test:codex-runtime": "node test/codex-runtime-probe.mjs",
2829
"test:claude-orchestration": "node test/claude-orchestration.mjs",
2930
"test:claude-autodiscovery": "node test/claude-autodiscovery.mjs",
3031
"test:claude-real-codex": "node test/claude-real-codex.mjs",
31-
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress",
32-
"test:comprehensive": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:codex-runtime && npm run validate:plugin && npm run test:claude-desktop",
32+
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress",
33+
"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:codex-runtime && npm run validate:plugin && npm run test:claude-desktop",
3334
"test:claude-desktop": "node test/claude-desktop-cli.mjs"
3435
},
3536
"keywords": [

0 commit comments

Comments
 (0)