Skip to content

Commit 0ebb3fc

Browse files
xuiocodex
andcommitted
Show Codex sessions as normal desktop threads
Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent f883bc6 commit 0ebb3fc

6 files changed

Lines changed: 111 additions & 7 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ debugging, or adversarial review.
2323
- **No daemon:** Claude launches the MCP server over stdio for the active session.
2424
- **Fast parallel review:** Claude can launch several independent Codex agents with bounded concurrency.
2525
- **Persistent sessions:** App-server sessions keep Codex context across prompts and support live steering.
26-
- **Codex desktop friendly:** The plugin prefers the Codex binary shipped inside `Codex.app` when it exists.
26+
- **Codex desktop friendly:** The plugin prefers the Codex binary shipped inside `Codex.app` and creates normal Codex threads with task names for Desktop history.
2727
- **Debuggable:** Verbose JSONL logging, diagnostics bundles, progress events, per-session resources, and recovery hints are built in.
2828

2929
## Quick Start
@@ -120,6 +120,11 @@ 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+
When app-server sessions use the Codex desktop binary, they are recorded as
124+
normal top-level Codex threads rather than hidden daemon work. The plugin sets the
125+
thread name from Claude's task label when the installed Codex app-server supports
126+
thread naming.
127+
123128
## Safety Model
124129

125130
The default execution mode is conservative:

dist/index.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24113,6 +24113,11 @@ var LimitedText2 = class {
2411324113
function userText(text) {
2411424114
return { type: "text", text, text_elements: [] };
2411524115
}
24116+
function desktopThreadName(name) {
24117+
const trimmed = name?.trim();
24118+
if (!trimmed) return void 0;
24119+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
24120+
}
2411624121
function sandboxPolicy(options) {
2411724122
if (options.dangerouslyBypassApprovalsAndSandbox) return { type: "dangerFullAccess" };
2411824123
switch (options.sandbox ?? "read-only") {
@@ -24330,14 +24335,16 @@ var CodexAppServerSession = class _CodexAppServerSession {
2433024335
sandbox: sandboxMode(options),
2433124336
config: appServerConfig(options, reasoningEffort),
2433224337
serviceName: "claude-code-codex-subagents",
24333-
ephemeral: options.ephemeral ?? false,
24334-
threadSource: "subagent"
24338+
ephemeral: options.ephemeral ?? false
2433524339
}, options.spawnTimeoutMs ?? 3e4);
2433624340
const threadId = thread.thread?.id;
2433724341
if (!threadId) throw new AppServerUnavailableError("Codex app-server did not return a thread id.");
2433824342
session.threadId = threadId;
2433924343
if (resumeThreadId) session.capabilities.threadResume = true;
2434024344
else session.capabilities.threadStart = true;
24345+
if (!resumeThreadId) {
24346+
await session.setThreadName(desktopThreadName(options.name), options.spawnTimeoutMs ?? 3e4);
24347+
}
2434124348
await session.probeThreadRead(options.spawnTimeoutMs ?? 3e4);
2434224349
return session;
2434324350
} catch (error2) {
@@ -24354,6 +24361,20 @@ var CodexAppServerSession = class _CodexAppServerSession {
2435424361
this.userAgent = typeof initialized?.userAgent === "string" ? initialized.userAgent : void 0;
2435524362
this.codexHome = typeof initialized?.codexHome === "string" ? initialized.codexHome : void 0;
2435624363
}
24364+
async setThreadName(name, timeoutMs) {
24365+
if (!name) return;
24366+
try {
24367+
await this.request("thread/name/set", { threadId: this.threadId, name }, timeoutMs);
24368+
} catch (error2) {
24369+
logger.warn("codex.app_server.thread_name_failed", {
24370+
...this.logContext,
24371+
appServerId: this.id,
24372+
threadId: this.threadId,
24373+
name,
24374+
error: errorForLog(error2)
24375+
});
24376+
}
24377+
}
2435724378
get activeTurnId() {
2435824379
return this.activeTurn?.turnId;
2435924380
}

docs/ARCHITECTURE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ flowchart LR
3030
The app-server process is a child of the MCP server. When Claude shuts down the
3131
MCP server, there is no background daemon left behind.
3232

33+
App-server threads are started as normal top-level Codex threads, not as nested
34+
Codex subagent threads. When the Codex app-server supports `thread/name/set`, the
35+
plugin best-effort names the thread from Claude's task label so the run is easy
36+
to find in Codex Desktop history.
37+
3338
## Binary Resolution
3439

3540
The resolver checks candidates in this order:

src/app-server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type AppServerRequestMethod =
3232
| "initialize"
3333
| "thread/start"
3434
| "thread/resume"
35+
| "thread/name/set"
3536
| "turn/start"
3637
| "turn/steer"
3738
| "turn/interrupt"
@@ -118,6 +119,12 @@ function userText(text: string): JsonObject {
118119
return { type: "text", text, text_elements: [] };
119120
}
120121

122+
function desktopThreadName(name: string | undefined): string | undefined {
123+
const trimmed = name?.trim();
124+
if (!trimmed) return undefined;
125+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
126+
}
127+
121128
function sandboxPolicy(options: AgentRunOptions): JsonObject {
122129
if (options.dangerouslyBypassApprovalsAndSandbox) return { type: "dangerFullAccess" };
123130
switch (options.sandbox ?? "read-only") {
@@ -347,13 +354,15 @@ export class CodexAppServerSession {
347354
config: appServerConfig(options, reasoningEffort),
348355
serviceName: "claude-code-codex-subagents",
349356
ephemeral: options.ephemeral ?? false,
350-
threadSource: "subagent",
351357
}, options.spawnTimeoutMs ?? 30_000) as { thread?: { id?: string }; cwd?: string };
352358
const threadId = thread.thread?.id;
353359
if (!threadId) throw new AppServerUnavailableError("Codex app-server did not return a thread id.");
354360
session.threadId = threadId;
355361
if (resumeThreadId) session.capabilities.threadResume = true;
356362
else session.capabilities.threadStart = true;
363+
if (!resumeThreadId) {
364+
await session.setThreadName(desktopThreadName(options.name), options.spawnTimeoutMs ?? 30_000);
365+
}
357366
await session.probeThreadRead(options.spawnTimeoutMs ?? 30_000);
358367
return session;
359368
} catch (error) {
@@ -372,6 +381,21 @@ export class CodexAppServerSession {
372381
this.codexHome = typeof initialized?.codexHome === "string" ? initialized.codexHome : undefined;
373382
}
374383

384+
private async setThreadName(name: string | undefined, timeoutMs: number): Promise<void> {
385+
if (!name) return;
386+
try {
387+
await this.request("thread/name/set", { threadId: this.threadId, name }, timeoutMs);
388+
} catch (error) {
389+
logger.warn("codex.app_server.thread_name_failed", {
390+
...this.logContext,
391+
appServerId: this.id,
392+
threadId: this.threadId,
393+
name,
394+
error: errorForLog(error),
395+
});
396+
}
397+
}
398+
375399
get activeTurnId(): string | undefined {
376400
return this.activeTurn?.turnId;
377401
}

test/fixtures/fake-codex.mjs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ if (args[0] === "app-server") {
5858
let activeTurn = undefined;
5959
let activePrompt = "";
6060
let activeSteers = [];
61+
let threadName = null;
6162
let activeTimer = undefined;
6263

6364
function modeText() {
@@ -215,6 +216,15 @@ if (args[0] === "app-server") {
215216
return;
216217
}
217218
threadId = `fake-thread-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
219+
threadName = null;
220+
recordCall({
221+
protocol: "app-server",
222+
method,
223+
threadId,
224+
cwd: params?.cwd ?? process.cwd(),
225+
threadSource: params?.threadSource ?? null,
226+
serviceName: params?.serviceName ?? null,
227+
});
218228
if (hasMode("THREAD_START_NO_ID")) {
219229
send({ id, result: { thread: {}, cwd: params?.cwd ?? process.cwd() } });
220230
return;
@@ -240,7 +250,7 @@ if (args[0] === "app-server") {
240250
agentNickname: null,
241251
agentRole: null,
242252
gitInfo: null,
243-
name: null,
253+
name: threadName,
244254
turns: [],
245255
},
246256
model: params?.model ?? "fake-model",
@@ -257,6 +267,16 @@ if (args[0] === "app-server") {
257267
send({ method: "thread/started", params: { thread: { id: threadId, cwd: params?.cwd ?? process.cwd(), turns: [] } } });
258268
return;
259269
}
270+
if (method === "thread/name/set") {
271+
threadName = typeof params?.name === "string" ? params.name : null;
272+
recordCall({ protocol: "app-server", method, threadId: params?.threadId ?? threadId, name: threadName });
273+
if (hasMode("THREAD_NAME_SET_ERROR")) {
274+
send({ id, error: { code: -32000, message: "fake thread name set error" } });
275+
return;
276+
}
277+
send({ id, result: {} });
278+
return;
279+
}
260280
if (method === "thread/resume") {
261281
if (hasMode("THREAD_RESUME_ERROR")) {
262282
send({ id, error: { code: -32000, message: "fake thread resume error" } });
@@ -441,7 +461,7 @@ if (args[0] === "app-server") {
441461
send({ id, error: { code: -32000, message: "fake thread read error" } });
442462
return;
443463
}
444-
send({ id, result: { thread: { id: threadId, turns: [] } } });
464+
send({ id, result: { thread: { id: threadId, name: threadName, turns: [] } } });
445465
return;
446466
}
447467
send({ id, error: { code: -32601, message: `unknown method ${method}` } });

test/sessions.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,32 @@ describe("CodexSessionManager", () => {
8080
manager.cancel(started.session.id);
8181
});
8282

83+
it("starts app-server sessions as normal desktop-visible Codex threads with task names", async () => {
84+
const manager = new CodexSessionManager();
85+
const projectDir = await tempDir("codex-subagents-session-project-");
86+
const recordDir = await tempDir("codex-subagents-session-record-");
87+
88+
const started = await manager.start({
89+
prompt: "desktop visible session",
90+
name: "Review desktop visibility",
91+
projectDir,
92+
codexBin: fakeCodex,
93+
env: {
94+
FAKE_CODEX_RECORD_DIR: recordDir,
95+
},
96+
});
97+
98+
expect(started.result.ok).toBe(true);
99+
const calls = await recordedCalls(recordDir);
100+
const threadStart = calls.find((call) => call.method === "thread/start");
101+
expect(threadStart?.threadSource).toBeNull();
102+
expect(threadStart?.serviceName).toBe("claude-code-codex-subagents");
103+
expect(calls.some((call) => call.method === "thread/name/set" && call.name === "Review desktop visibility")).toBe(
104+
true,
105+
);
106+
manager.cancel(started.session.id);
107+
});
108+
83109
it("delivers steering to the active app-server turn", async () => {
84110
const manager = new CodexSessionManager();
85111
const projectDir = await tempDir("codex-subagents-session-project-");
@@ -110,7 +136,10 @@ describe("CodexSessionManager", () => {
110136
expect(waited.session?.lastResult?.finalMessage).toContain("steer this active turn");
111137

112138
const calls = await recordedCalls(recordDir);
113-
expect(calls.map((call) => call.method).filter((method) => method !== "thread/read")).toEqual(["turn/start", "turn/steer"]);
139+
expect(calls.map((call) => call.method).filter((method) => method === "turn/start" || method === "turn/steer")).toEqual([
140+
"turn/start",
141+
"turn/steer",
142+
]);
114143
manager.cancel(session.id);
115144
});
116145

0 commit comments

Comments
 (0)