Skip to content

Commit 39c116b

Browse files
xuiocodex
andcommitted
Archive closed Codex desktop threads
Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 0ebb3fc commit 39c116b

12 files changed

Lines changed: 274 additions & 29 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,10 @@ turns, Claude should set `background: true`.
123123
When app-server sessions use the Codex desktop binary, they are recorded as
124124
normal top-level Codex threads rather than hidden daemon work. The plugin sets the
125125
thread name from Claude's task label when the installed Codex app-server supports
126-
thread naming.
126+
thread naming. When Claude explicitly cancels a session, or when retention
127+
pruning removes an old session, the plugin best-effort archives the matching
128+
Codex Desktop thread before closing the app-server child. It does not hard-delete
129+
threads, and recoverable sessions are not archived during normal MCP shutdown.
127130

128131
## Safety Model
129132

dist/index.js

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24375,6 +24375,26 @@ var CodexAppServerSession = class _CodexAppServerSession {
2437524375
});
2437624376
}
2437724377
}
24378+
async archiveThread(timeoutMs = 2e3) {
24379+
if (!this.threadId) return false;
24380+
try {
24381+
await this.request("thread/archive", { threadId: this.threadId }, timeoutMs);
24382+
logger.info("codex.app_server.thread_archived", {
24383+
...this.logContext,
24384+
appServerId: this.id,
24385+
threadId: this.threadId
24386+
});
24387+
return true;
24388+
} catch (error2) {
24389+
logger.warn("codex.app_server.thread_archive_failed", {
24390+
...this.logContext,
24391+
appServerId: this.id,
24392+
threadId: this.threadId,
24393+
error: errorForLog(error2)
24394+
});
24395+
return false;
24396+
}
24397+
}
2437824398
get activeTurnId() {
2437924399
return this.activeTurn?.turnId;
2438024400
}
@@ -25678,12 +25698,11 @@ var CodexSessionManager = class {
2567825698
if (session.controller) {
2567925699
session.status = "cancelled";
2568025700
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25681-
void session.appServer?.close("cancelled");
2568225701
session.controller.abort();
2568325702
} else {
2568425703
session.status = "cancelled";
2568525704
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25686-
void session.appServer?.close("cancelled");
25705+
this.closeAppServer(session, "cancelled", "cancel");
2568725706
}
2568825707
this.appendMilestones(session, [
2568925708
{
@@ -25968,6 +25987,9 @@ var CodexSessionManager = class {
2596825987
this.notifyTurn(turn);
2596925988
this.notifySession(session);
2597025989
this.persist();
25990+
if (controller.signal.aborted && !session.runtimeShutdownRecoverable) {
25991+
this.closeAppServer(session, "cancelled", "cancel");
25992+
}
2597125993
throw error2;
2597225994
} finally {
2597325995
session.controller = void 0;
@@ -26096,7 +26118,7 @@ var CodexSessionManager = class {
2609626118
turn: summarizeRawTrafficForLog(turnSnapshot(turn)),
2609726119
result: summarizeRawTrafficForLog(result)
2609826120
});
26099-
if (session.cancelRequested && session.appServer) void session.appServer.close("cancelled");
26121+
if (session.cancelRequested && session.appServer) this.closeAppServer(session, "cancelled", "cancel");
2610026122
this.notifyTurn(turn);
2610126123
this.notifySession(session);
2610226124
this.persist();
@@ -26273,12 +26295,28 @@ var CodexSessionManager = class {
2627326295
this.sessions.delete(id);
2627426296
if (session.resourceNotifyTimer) clearTimeout(session.resourceNotifyTimer);
2627526297
if (session.resourceNotifyMaxTimer) clearTimeout(session.resourceNotifyMaxTimer);
26276-
void session.appServer?.close("cancelled").catch(() => {
26277-
});
26298+
this.closeAppServer(session, "cancelled", `prune_${reason}`);
2627826299
this.notifySession(session);
2627926300
this.emitSessionChanged(id);
2628026301
this.persist();
2628126302
}
26303+
closeAppServer(session, status, archiveReason) {
26304+
const appServer = session.appServer;
26305+
if (!appServer) return;
26306+
const close = () => appServer.close(status).catch(() => {
26307+
});
26308+
if (appServer.status().closed) return;
26309+
if (!archiveReason || session.protocol !== "app-server" || !session.codexThreadId) {
26310+
void close();
26311+
return;
26312+
}
26313+
logger.info("session.archive_app_server_thread", {
26314+
sessionId: session.id,
26315+
threadId: session.codexThreadId,
26316+
reason: archiveReason
26317+
});
26318+
void appServer.archiveThread().finally(close);
26319+
}
2628226320
loadPersistedSessions() {
2628326321
const store = this.stateStore;
2628426322
if (!store) return;
@@ -26400,7 +26438,7 @@ var usageGuide = [
2640026438
"- Approvals are non-interactive; do not expect Codex to ask permission.",
2640126439
'- If codex_followup mode wait returns completed false with timeoutReason "wait_timeout", the session is still running unless its status says otherwise.',
2640226440
"- Use codex_wait_any after launching several background Codex tasks to harvest whichever one finishes first without busy-polling.",
26403-
"- Use codex_followup mode cancel to stop a background or actively running Codex session early. The response includes whatever partial output streamed before the interrupt.",
26441+
"- Use codex_followup mode cancel to stop a background or actively running Codex session early. The response includes whatever partial output streamed before the interrupt, and the matching Codex Desktop thread is archived best-effort when supported.",
2640426442
'- If a tool returns error.kind "backpressure", reduce max_parallel or wait before retrying. codex://status exposes current queue/session limits.',
2640526443
"- If a response mentions outputArtifacts, use the artifact paths for full retained output instead of asking Codex to resend huge stdout/stderr.",
2640626444
'- Do not use model_preset "spark" by default. Use Spark only when the user asks for Spark or when a quick focused sidecar check is clearly more appropriate than the default Codex model.',
@@ -28177,8 +28215,9 @@ registerTool(
2817728215
const activeTurn = sessionBefore.activeTurn;
2817828216
const lastResult = sessionBefore.lastResult;
2817928217
if (!wasActive && sessionBefore.queuedTurns.length === 0 && lastResult?.status === "completed") {
28218+
const cancelled2 = sessionManager.cancel(args.session_id, args.reason ?? "closed after completion");
2818028219
await progress.flush();
28181-
const compactSession3 = compactSessionSnapshotForMcp(sessionBefore);
28220+
const compactSession3 = compactSessionSnapshotForMcp(cancelled2 ?? sessionBefore);
2818228221
const compactResult = compactAgentResultForMcp(lastResult);
2818328222
const resultValue = compactResult.structuredOutput ?? compactResult.finalMessage;
2818428223
const resultText = stringifyResultValue(resultValue, compactResult.finalMessage);

docs/ARCHITECTURE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ MCP server, there is no background daemon left behind.
3333
App-server threads are started as normal top-level Codex threads, not as nested
3434
Codex subagent threads. When the Codex app-server supports `thread/name/set`, the
3535
plugin best-effort names the thread from Claude's task label so the run is easy
36-
to find in Codex Desktop history.
36+
to find in Codex Desktop history. When the app-server supports `thread/archive`,
37+
explicit session cancellation and retention pruning best-effort archive the
38+
Desktop thread before the child process is closed. Archive failures are logged and
39+
do not block cancellation.
3740

3841
## Binary Resolution
3942

@@ -96,7 +99,9 @@ When limits are exceeded, tools return structured recovery hints instead of
9699
accepting unbounded work.
97100

98101
Completed jobs, idle sessions, and terminal sessions are pruned according to the
99-
configured retention windows.
102+
configured retention windows. Pruning an app-server-backed session also
103+
best-effort archives the Codex Desktop thread. Normal MCP runtime shutdown keeps
104+
recoverable sessions unarchived so Claude can reattach with the same `session_id`.
100105

101106
## Logging And Diagnostics
102107

docs/USAGE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ Use this decision path when writing prompts or debugging Claude tool choice:
7474
| Wait for a background session | `codex_followup` with `mode: "wait"` |
7575
| Stop a background or running session | `codex_followup` with `mode: "cancel"` |
7676

77+
`mode: "cancel"` also closes the associated app-server session. If Codex Desktop
78+
supports thread archiving, the plugin best-effort archives that Desktop thread so
79+
stopped Claude subagent work does not keep cluttering the active thread list.
80+
7781
When in doubt, read `codex://usage` and then choose among the native front-door tools.
7882

7983
## When To Prefer Codex

src/app-server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type AppServerRequestMethod =
3333
| "thread/start"
3434
| "thread/resume"
3535
| "thread/name/set"
36+
| "thread/archive"
3637
| "turn/start"
3738
| "turn/steer"
3839
| "turn/interrupt"
@@ -396,6 +397,27 @@ export class CodexAppServerSession {
396397
}
397398
}
398399

400+
async archiveThread(timeoutMs = 2_000): Promise<boolean> {
401+
if (!this.threadId) return false;
402+
try {
403+
await this.request("thread/archive", { threadId: this.threadId }, timeoutMs);
404+
logger.info("codex.app_server.thread_archived", {
405+
...this.logContext,
406+
appServerId: this.id,
407+
threadId: this.threadId,
408+
});
409+
return true;
410+
} catch (error) {
411+
logger.warn("codex.app_server.thread_archive_failed", {
412+
...this.logContext,
413+
appServerId: this.id,
414+
threadId: this.threadId,
415+
error: errorForLog(error),
416+
});
417+
return false;
418+
}
419+
}
420+
399421
get activeTurnId(): string | undefined {
400422
return this.activeTurn?.turnId;
401423
}

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const usageGuide = [
8787
"- Approvals are non-interactive; do not expect Codex to ask permission.",
8888
"- If codex_followup mode wait returns completed false with timeoutReason \"wait_timeout\", the session is still running unless its status says otherwise.",
8989
"- Use codex_wait_any after launching several background Codex tasks to harvest whichever one finishes first without busy-polling.",
90-
"- Use codex_followup mode cancel to stop a background or actively running Codex session early. The response includes whatever partial output streamed before the interrupt.",
90+
"- Use codex_followup mode cancel to stop a background or actively running Codex session early. The response includes whatever partial output streamed before the interrupt, and the matching Codex Desktop thread is archived best-effort when supported.",
9191
"- If a tool returns error.kind \"backpressure\", reduce max_parallel or wait before retrying. codex://status exposes current queue/session limits.",
9292
"- If a response mentions outputArtifacts, use the artifact paths for full retained output instead of asking Codex to resend huge stdout/stderr.",
9393
"- Do not use model_preset \"spark\" by default. Use Spark only when the user asks for Spark or when a quick focused sidecar check is clearly more appropriate than the default Codex model.",
@@ -2619,8 +2619,9 @@ registerTool(
26192619
const activeTurn = sessionBefore.activeTurn;
26202620
const lastResult = sessionBefore.lastResult;
26212621
if (!wasActive && sessionBefore.queuedTurns.length === 0 && lastResult?.status === "completed") {
2622+
const cancelled = sessionManager.cancel(args.session_id, args.reason ?? "closed after completion");
26222623
await progress.flush();
2623-
const compactSession = compactSessionSnapshotForMcp(sessionBefore);
2624+
const compactSession = compactSessionSnapshotForMcp(cancelled ?? sessionBefore);
26242625
const compactResult = compactAgentResultForMcp(lastResult);
26252626
const resultValue = compactResult.structuredOutput ?? compactResult.finalMessage;
26262627
const resultText = stringifyResultValue(resultValue, compactResult.finalMessage);

src/sessions.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -880,12 +880,11 @@ export class CodexSessionManager {
880880
if (session.controller) {
881881
session.status = "cancelled";
882882
session.updatedAt = new Date().toISOString();
883-
void session.appServer?.close("cancelled");
884883
session.controller.abort();
885884
} else {
886885
session.status = "cancelled";
887886
session.updatedAt = new Date().toISOString();
888-
void session.appServer?.close("cancelled");
887+
this.closeAppServer(session, "cancelled", "cancel");
889888
}
890889
this.appendMilestones(session, [
891890
{
@@ -1195,6 +1194,9 @@ export class CodexSessionManager {
11951194
this.notifyTurn(turn);
11961195
this.notifySession(session);
11971196
this.persist();
1197+
if (controller.signal.aborted && !session.runtimeShutdownRecoverable) {
1198+
this.closeAppServer(session, "cancelled", "cancel");
1199+
}
11981200
throw error;
11991201
} finally {
12001202
session.controller = undefined;
@@ -1355,7 +1357,7 @@ export class CodexSessionManager {
13551357
turn: summarizeRawTrafficForLog(turnSnapshot(turn)),
13561358
result: summarizeRawTrafficForLog(result),
13571359
});
1358-
if (session.cancelRequested && session.appServer) void session.appServer.close("cancelled");
1360+
if (session.cancelRequested && session.appServer) this.closeAppServer(session, "cancelled", "cancel");
13591361
this.notifyTurn(turn);
13601362
this.notifySession(session);
13611363
this.persist();
@@ -1570,12 +1572,33 @@ export class CodexSessionManager {
15701572
this.sessions.delete(id);
15711573
if (session.resourceNotifyTimer) clearTimeout(session.resourceNotifyTimer);
15721574
if (session.resourceNotifyMaxTimer) clearTimeout(session.resourceNotifyMaxTimer);
1573-
void session.appServer?.close("cancelled").catch(() => {});
1575+
this.closeAppServer(session, "cancelled", `prune_${reason}`);
15741576
this.notifySession(session);
15751577
this.emitSessionChanged(id);
15761578
this.persist();
15771579
}
15781580

1581+
private closeAppServer(
1582+
session: CodexSessionRecord,
1583+
status: "failed" | "cancelled",
1584+
archiveReason?: string,
1585+
): void {
1586+
const appServer = session.appServer;
1587+
if (!appServer) return;
1588+
const close = () => appServer.close(status).catch(() => {});
1589+
if (appServer.status().closed) return;
1590+
if (!archiveReason || session.protocol !== "app-server" || !session.codexThreadId) {
1591+
void close();
1592+
return;
1593+
}
1594+
logger.info("session.archive_app_server_thread", {
1595+
sessionId: session.id,
1596+
threadId: session.codexThreadId,
1597+
reason: archiveReason,
1598+
});
1599+
void appServer.archiveThread().finally(close);
1600+
}
1601+
15791602
private loadPersistedSessions(): void {
15801603
const store = this.stateStore;
15811604
if (!store) return;

test/app-server-hardening.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,50 @@ describe("app-server hardening", () => {
269269

270270
const becameActive = await waitFor(() => Boolean(manager.get(session.id)?.appServer?.activeTurnId));
271271
expect(becameActive).toBe(true);
272+
const codexThreadId = manager.get(session.id)?.codexThreadId;
273+
expect(codexThreadId).toBeTruthy();
272274
const cancelled = manager.cancel(session.id);
273275
expect(cancelled?.status).toBe("cancelled");
274276

275-
const sawSigterm = await waitFor(async () => {
276-
const calls = await recordedCalls(recordDir);
277-
return calls.some((call) => call.method === "process/sigterm");
277+
let finalCalls: Array<Record<string, unknown>> = [];
278+
const sawCleanup = await waitFor(async () => {
279+
finalCalls = await recordedCalls(recordDir);
280+
return (
281+
finalCalls.some((call) => call.method === "turn/interrupt") &&
282+
finalCalls.some((call) => call.method === "thread/archive") &&
283+
finalCalls.some((call) => call.method === "process/sigterm")
284+
);
278285
}, 10_000);
279-
expect(sawSigterm).toBe(true);
286+
expect(sawCleanup).toBe(true);
287+
expect(finalCalls.some((call) => call.method === "thread/archive" && call.threadId === codexThreadId)).toBe(true);
280288
}, 15_000);
281289

290+
it("still closes app-server sessions when desktop thread archiving is unsupported", async () => {
291+
const manager = new CodexSessionManager();
292+
const projectDir = await tempDir("codex-subagents-app-archive-unsupported-project-");
293+
const recordDir = await tempDir("codex-subagents-app-archive-unsupported-record-");
294+
295+
const { session, result } = await manager.start({
296+
prompt: "archive unsupported session",
297+
projectDir,
298+
codexBin: fakeCodex,
299+
env: {
300+
FAKE_CODEX_RECORD_DIR: recordDir,
301+
FAKE_CODEX_APP_SERVER_MODE: "THREAD_ARCHIVE_ERROR",
302+
},
303+
});
304+
305+
expect(result.ok).toBe(true);
306+
const cancelled = manager.cancel(session.id);
307+
expect(cancelled?.status).toBe("cancelled");
308+
309+
const sawCleanup = await waitFor(async () => {
310+
const calls = await recordedCalls(recordDir);
311+
return calls.some((call) => call.method === "thread/archive") && calls.some((call) => call.method === "process/sigterm");
312+
});
313+
expect(sawCleanup).toBe(true);
314+
});
315+
282316
it("keeps concurrent app-server sessions isolated", async () => {
283317
const manager = new CodexSessionManager();
284318
const projectDir = await tempDir("codex-subagents-app-concurrent-project-");

test/app-server-protocol-contract.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ try {
3838
"v2/ThreadStartParams.json",
3939
"v2/ThreadResumeParams.json",
4040
"v2/ThreadResumeResponse.json",
41+
"v2/ThreadArchiveParams.json",
42+
"v2/ThreadArchiveResponse.json",
4143
"v2/TurnStartParams.json",
4244
"v2/TurnSteerParams.json",
4345
"v2/TurnInterruptParams.json",
@@ -68,6 +70,9 @@ try {
6870
assert(threadResume.properties?.model, "thread/resume must keep model override support");
6971
assert(threadResume.properties?.sandbox, "thread/resume must keep sandbox override support");
7072

73+
const threadArchive = await readSchema(out, "v2/ThreadArchiveParams.json");
74+
assert(required(threadArchive).has("threadId"), "thread/archive must require threadId", threadArchive.required);
75+
7176
const turnSteer = await readSchema(out, "v2/TurnSteerParams.json");
7277
assert(required(turnSteer).has("threadId"), "turn/steer must require threadId", turnSteer.required);
7378
assert(required(turnSteer).has("expectedTurnId"), "turn/steer must require expectedTurnId", turnSteer.required);

test/fixtures/fake-codex.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ if (args[0] === "app-server") {
5959
let activePrompt = "";
6060
let activeSteers = [];
6161
let threadName = null;
62+
let threadArchived = false;
6263
let activeTimer = undefined;
6364

6465
function modeText() {
@@ -217,6 +218,7 @@ if (args[0] === "app-server") {
217218
}
218219
threadId = `fake-thread-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
219220
threadName = null;
221+
threadArchived = false;
220222
recordCall({
221223
protocol: "app-server",
222224
method,
@@ -277,6 +279,16 @@ if (args[0] === "app-server") {
277279
send({ id, result: {} });
278280
return;
279281
}
282+
if (method === "thread/archive") {
283+
threadArchived = true;
284+
recordCall({ protocol: "app-server", method, threadId: params?.threadId ?? threadId });
285+
if (hasMode("THREAD_ARCHIVE_ERROR")) {
286+
send({ id, error: { code: -32000, message: "fake thread archive error" } });
287+
return;
288+
}
289+
send({ id, result: {} });
290+
return;
291+
}
280292
if (method === "thread/resume") {
281293
if (hasMode("THREAD_RESUME_ERROR")) {
282294
send({ id, error: { code: -32000, message: "fake thread resume error" } });
@@ -461,7 +473,7 @@ if (args[0] === "app-server") {
461473
send({ id, error: { code: -32000, message: "fake thread read error" } });
462474
return;
463475
}
464-
send({ id, result: { thread: { id: threadId, name: threadName, turns: [] } } });
476+
send({ id, result: { thread: { id: threadId, name: threadName, archived: threadArchived, turns: [] } } });
465477
return;
466478
}
467479
send({ id, error: { code: -32601, message: `unknown method ${method}` } });

0 commit comments

Comments
 (0)