@minhspark/codex-mcp-bridge 1.12.4 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.13.0] - 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - Opt-in Desktop task delivery: `delegate_to_codex` creates a task in the exact saved local project and opens it while running. `send_to_codex_thread` continues through Desktop without a second writer. `start_codex_thread` accepts an initial prompt and requires it in Desktop mode.
10
+ - Enable with `codex-native-relay-install --desktop-tasks` or `CODEX_BRIDGE_DESKTOP_TASKS=1`. Desktop mode uses Desktop permissions and preserves bridge workspace/thread authorization. Missing, ambiguous, remote, or parent-only project matches fail before creation; the requested checkout is preserved.
11
+ - Strictly allowlisted native operations and a separate Desktop task socket allow upgrades alongside an older legacy relay owner. Read/open operations use Desktop too; timed-out native tasks point to Desktop's Stop control.
12
+
13
+ ### Fixed
14
+
15
+ - Distinguish task acceptance, model failure, attention requests, and observation timeout. Follow-up polling ignores the previous completed turn; unconfirmed creation or sending is never automatically retried through another backend.
16
+ - Mark prompt-bearing task creation as potentially destructive in MCP annotations, and preserve the shared Desktop opt-in when bootstrapping a relay executor.
17
+
18
+ ### Verification
19
+
20
+ - Added project matching, authorization, protocol validation, socket transport, uncertain acknowledgement, attention, timeout, and full MCP creation/opening tests.
21
+ - A real Windows Desktop task appeared under PCC4SH and completed with `cwd=C:\PCC4SH`, `HEAD=74ed553`. A subsequent native message reached that same task and reported an account usage-limit failure; a second successful model response was not claimed.
22
+
23
+ ## [1.12.5] - 2026-09-05
24
+
25
+ ### Fixed
26
+
27
+ - Preserve the configured environment when the Claude diagnostic starts its MCP child, including sender permission mode and native-relay settings. The MCP SDK's default environment whitelist otherwise drops these settings and can make the diagnostic exercise a different receiver policy than the installed bridge.
28
+
29
+ ### Verification
30
+
31
+ - The live Codex MCP bridge returned a real Claude Desktop answer through the UUID-correlated transcript path with the expected workspace. The native Codex relay also delivered its marker into the existing Desktop task. No receiver permission settings or Desktop tool restrictions were changed.
32
+ - The live `check:claude` command reported bridge 1.12.5, received the correlated Desktop response, and exited with code 0.
33
+
5
34
  ## [1.12.4] - 2026-09-05
6
35
 
7
36
  ### Fixed
package/README.md CHANGED
@@ -8,7 +8,23 @@
8
8
 
9
9
  A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction uses unix sockets on macOS/Linux and named pipes on Windows).
10
10
 
11
- This is not `codex exec`, which starts a fresh session every time. The bridge speaks JSON-RPC to the real Codex app-server, so the thread keeps its history, its `cwd`, its model and its rollout file and a human can watch it run in the Codex desktop app instead of reading the transcript afterwards.
11
+ The bridge preserves task history and working directories across messages. Enable **Desktop tasks** to create, assign, and watch work directly in Codex Desktop. The separate app-server backend remains available for CLI use; it cannot provide live Desktop viewing while it owns the task's writer lock.
12
+
13
+ ### Visible tasks in the correct Desktop project
14
+
15
+ Install the native companion, add the exact checkout directory as a local project in Codex Desktop, and enable Desktop task delivery:
16
+
17
+ ```bash
18
+ codex-native-relay-install --desktop-tasks
19
+ ```
20
+
21
+ Reload the native companion and the Claude MCP client after upgrading. The installer stores the opt-in in the existing `~/.codex/native-relay.json`; `CODEX_BRIDGE_DESKTOP_TASKS=1` also enables it, and an explicit `0` overrides the shared setting. Desktop mode uses **Codex Desktop permissions**, while the bridge's workspace and thread authorization checks still apply. Existing installations keep their app-server permission settings unless they opt in.
22
+
23
+ Call `delegate_to_codex` with `cwd`, `prompt`, and optionally `name`. The bridge resolves the real directory, selects the saved local project with that exact path, starts in its existing checkout, and opens the task immediately while it runs. `openInApp: false` suppresses page navigation; project assignment still happens. It never selects a parent directory, remote namesake, or a new worktree. An unsaved worktree must first be saved as its own local project; missing or ambiguous project matches return an error before creating a task.
24
+
25
+ `start_codex_thread` requires `prompt` in Desktop mode and returns after acceptance. Use `delegate_to_codex` to also wait for the reply. `send_to_codex_thread` continues the existing Desktop task without attaching another writer; its cwd cannot be changed. On timeout, the task continues. Inspect it in Desktop and use its Stop button to interrupt it. Quota failures and approval/input requests remain visible; delivery does not bypass them. An uncertain creation or send is never automatically repeated through another backend.
26
+
27
+ The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` reports the selected mode; `native_relay_status` reports both endpoints.
12
28
 
13
29
  ## Architecture
14
30
 
@@ -16,7 +32,7 @@ Two MCP servers, one living inside each agent:
16
32
 
17
33
  ```
18
34
  ┌──────────────── codex-mcp-bridge (runs inside Claude) ──────────────┐
19
- Claude Desktop ──────┤ stdio WebSocket ├──> codex app-server ──> thread shows in Codex Desktop
35
+ Claude Desktop ──────┤ stdio WebSocket ├──> separate codex app-server
20
36
  └────────────────────────────────────────────────────────────────────┘
21
37
 
22
38
  ┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
@@ -26,14 +42,14 @@ Codex ───────────────┤ stdio unix so
26
42
  Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
27
43
 
28
44
  ┌── codex-native-relay (launched by Codex Desktop, Windows/macOS) ────┐
29
- claude-bridge ───────┤ named pipe / unix socket native tools ├──> the thread already open in Codex Desktop
45
+ both bridges ───────┤ named pipe / unix socket native tools ├──> visible project tasks in Codex Desktop
30
46
  └────────────────────────────────────────────────────────────────────┘
31
47
  ```
32
48
 
33
49
  - The app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits.
34
50
  - Every client pointed at the same URL shares **one app-server**, so `thread/resume` with a `threadId` rejoins the running thread instead of opening a new session.
35
51
  - The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
36
- - `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running.
52
+ - In app-server mode, `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running. Desktop mode instead creates and assigns the task through the app immediately.
37
53
  - The **native relay** (Windows/macOS, optional) is the third line: a thread the human is watching in Codex Desktop belongs to the app, and a second app-server cannot write to it. Instead of taking the thread away, `claude-bridge` hands the message to a companion the app itself launched, and the app delivers it. See [Codex Desktop native relay](#codex-desktop-native-relay).
38
54
 
39
55
  ## Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.4",
3
+ "version": "1.13.0",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -7,6 +7,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const transport = new StdioClientTransport({
8
8
  command: process.execPath,
9
9
  args: [path.join(root, "src", "claude-bridge.mjs")],
10
+ env: { ...process.env },
10
11
  stderr: "inherit",
11
12
  });
12
13
  const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
package/scripts/check.mjs CHANGED
@@ -13,6 +13,8 @@ const bridgeEnvNames = [
13
13
  "CODEX_BRIDGE_AUTO_APPROVE_ACK",
14
14
  "CODEX_BRIDGE_APPROVAL_POLICY",
15
15
  "CODEX_BRIDGE_AUTOSTART",
16
+ "CODEX_BRIDGE_DESKTOP_TASKS",
17
+ "CODEX_NATIVE_RELAY_SOCKET",
16
18
  "CODEX_BRIDGE_EFFORT",
17
19
  "CODEX_BRIDGE_MODEL",
18
20
  "CODEX_BRIDGE_PATH_MAP",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
7
+ import { desktopTasksConfigured } from "../src/native-relay.mjs";
7
8
 
8
9
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
@@ -85,6 +86,7 @@ cfg.mcpServers["codex-bridge"] = {
85
86
  CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
86
87
  CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
87
88
  CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
89
+ CODEX_BRIDGE_DESKTOP_TASKS: settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0"),
88
90
  ...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
89
91
  ? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
90
92
  : {}),
@@ -5,10 +5,10 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
- import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
8
+ import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath, writeRelayConfig } from "../src/native-relay.mjs";
9
9
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
10
10
 
11
- const VERSION = "1.12.4";
11
+ const VERSION = "1.13.0";
12
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
13
  const entry = path.join(root, "src", "native-relay-companion.mjs");
14
14
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -81,5 +81,9 @@ if (existing) {
81
81
  }
82
82
 
83
83
  console.log(`\nrelay socket: ${relaySocketPath()}`);
84
+ if (process.argv.includes("--desktop-tasks")) {
85
+ writeRelayConfig({ ...readRelayConfig(), desktopTasks: true });
86
+ console.log("Desktop task creation enabled: exact saved local projects, immediate visibility, Codex Desktop permissions.");
87
+ }
84
88
  console.log("Restart Codex Desktop so it launches the companion, then check with native_relay_status.");
85
89
  console.log("remove: node scripts/install-native-relay.mjs --remove");
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
8
8
  import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
 
11
- const VERSION = "1.12.4";
11
+ const VERSION = "1.13.0";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
package/src/index.mjs CHANGED
@@ -22,8 +22,10 @@ import {
22
22
  } from "./platform.mjs";
23
23
  import { runTurn } from "./turn.mjs";
24
24
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
25
+ import { DesktopTaskDelivery } from "./thread-delivery.mjs";
26
+ import { desktopTasksConfigured } from "./native-relay.mjs";
25
27
 
26
- const VERSION = "1.12.4";
28
+ const VERSION = "1.13.0";
27
29
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
28
30
 
29
31
  /**
@@ -42,6 +44,8 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
42
44
  const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
43
45
  const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
44
46
  const security = new BridgeSecurityPolicy();
47
+ const desktopTasksEnabled = desktopTasksConfigured();
48
+ const desktopTasks = new DesktopTaskDelivery({ security });
45
49
 
46
50
  const client = new CodexAppServerClient({
47
51
  clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
@@ -106,6 +110,36 @@ function threadNameFor({ cwd, prompt, name }) {
106
110
  );
107
111
  }
108
112
 
113
+ async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
114
+ const workspace = resolveWorkspacePath(cwd);
115
+ const created = await desktopTasks.create({
116
+ cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
117
+ model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT,
118
+ });
119
+ const notes = [];
120
+ if (workspace.note) notes.push(workspace.note);
121
+ if (openInApp ?? DEFAULT_OPEN_IN_APP) {
122
+ try {
123
+ await desktopTasks.open(created.threadId);
124
+ notes.push("opened in Codex Desktop while the task runs");
125
+ } catch (err) {
126
+ notes.push(`task was accepted; opening its page failed: ${err.message}`);
127
+ }
128
+ }
129
+ const lines = [
130
+ "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
131
+ `cwd: ${created.cwd}`, `projectId: ${created.projectId}`, `project: ${created.projectName}`,
132
+ "permissions: Codex Desktop settings; no external app-server writer", ...notes,
133
+ ];
134
+ if (!waitForReply) return textResult([...lines, "status: accepted; the task is running in Desktop"].join("\n"));
135
+ try {
136
+ const result = await desktopTasks.wait(created.threadId, { timeoutMs: (timeoutSec ?? 240) * 1000 });
137
+ return textResult([...lines, "", formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
138
+ } catch (err) {
139
+ return textResult([...lines, `Task was accepted; observation failed: ${err.message}`, "Do not resend the prompt. Inspect the existing task."].join("\n"), true);
140
+ }
141
+ }
142
+
109
143
  async function createCodexThread({ cwd, model, name, prompt }) {
110
144
  const workspace = resolveWorkspacePath(cwd);
111
145
  security.assertCwd(workspace.path);
@@ -189,7 +223,7 @@ function formatThreadRow(t) {
189
223
  return `- ${t.id}\n title: ${title}\n cwd: ${t.cwd ?? "?"}\n updated: ${updated} status: ${status} source: ${t.source ?? "?"}${deepLink}${authorized}`;
190
224
  }
191
225
 
192
- function formatTurn(result) {
226
+ function formatTurn(result, { desktop = false } = {}) {
193
227
  const lines = [];
194
228
  lines.push(`thread: ${result.threadId}`);
195
229
  lines.push(`turn: ${result.turnId ?? "?"} status: ${result.status}`);
@@ -214,8 +248,9 @@ function formatTurn(result) {
214
248
  if (result.status === "timeout") {
215
249
  lines.push(
216
250
  "",
217
- "NOTE: the bridge stopped waiting, but the turn is still running inside Codex.",
218
- `Read it later with read_codex_thread, or stop it with interrupt_codex_turn (turnId ${result.turnId}).`,
251
+ "NOTE: the bridge stopped waiting; it did not pause or cancel the task.",
252
+ desktop ? "Inspect the task in Codex Desktop; use its Stop button to stop the running turn. Do not resend the prompt." :
253
+ `Read it later with read_codex_thread, or stop it with interrupt_codex_turn (turnId ${result.turnId}).`,
219
254
  );
220
255
  }
221
256
  if (result.status === "disconnected") {
@@ -234,7 +269,8 @@ const server = new McpServer(
234
269
  {
235
270
  instructions:
236
271
  "Bridge Claude work into Codex. Prefer delegate_to_codex: it creates a named Codex thread at the " +
237
- "requested cwd, sends the prompt, releases the bridge writer lock, and opens the exact thread in " +
272
+ "requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
273
+ "Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
238
274
  "Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
239
275
  "list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring.",
240
276
  },
@@ -266,7 +302,7 @@ server.registerTool(
266
302
  openInApp: z
267
303
  .boolean()
268
304
  .optional()
269
- .describe("Open the finished session in Codex Desktop on Windows or macOS"),
305
+ .describe("Show the task in Codex Desktop; native tasks open immediately while running"),
270
306
  releaseAfterTurn: z
271
307
  .boolean()
272
308
  .optional()
@@ -284,6 +320,7 @@ server.registerTool(
284
320
  const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
285
321
  const notes = [];
286
322
  try {
323
+ if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp });
287
324
  const created = await createCodexThread({ cwd, prompt, name, model });
288
325
  if (created.workspace.note) notes.push(created.workspace.note);
289
326
  if (shouldOpen && !shouldRelease) {
@@ -376,6 +413,26 @@ server.registerTool(
376
413
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
377
414
  const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
378
415
  try {
416
+ if (desktopTasksEnabled) {
417
+ const workspace = cwd ? resolveWorkspacePath(cwd) : null;
418
+ if (workspace) security.assertCwd(workspace.path);
419
+ const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name });
420
+ notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
421
+ if (shouldOpen) {
422
+ try {
423
+ await desktopTasks.open(threadId);
424
+ notes.push("opened in Codex Desktop while the task runs");
425
+ } catch (err) {
426
+ notes.push(`task was accepted; opening its page failed: ${err.message}`);
427
+ }
428
+ }
429
+ try {
430
+ const result = await desktopTasks.wait(threadId, { timeoutMs: (timeoutSec ?? 240) * 1000, previousTurnId: delivered.previousTurnId });
431
+ return textResult([...notes, formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
432
+ } catch (err) {
433
+ return textResult([...notes, `threadId: ${threadId}`, `Task was accepted; observation failed: ${err.message}. Do not resend.`].join("\n"), true);
434
+ }
435
+ }
379
436
  const authorizedThread = await assertThreadAccess(threadId);
380
437
  let resolvedCwd = null;
381
438
  if (cwd) {
@@ -519,21 +576,27 @@ server.registerTool(
519
576
  "start_codex_thread",
520
577
  {
521
578
  title: "Start a new Codex thread",
522
- description: "Create a brand new Codex thread in the shared app-server and return its threadId.",
579
+ description: "Start a Codex task. In Desktop mode include the initial prompt to create and assign a visible task atomically; use delegate_to_codex to also wait for its reply.",
523
580
  inputSchema: {
524
581
  cwd: z.string().describe("Absolute working directory for the new Codex session"),
582
+ prompt: z.string().min(1).optional().describe("Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately"),
525
583
  model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
526
584
  name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
527
585
  },
528
586
  annotations: {
529
587
  readOnlyHint: false,
530
- destructiveHint: false,
588
+ destructiveHint: true,
531
589
  idempotentHint: false,
532
590
  openWorldHint: true,
533
591
  },
534
592
  },
535
- async ({ cwd, model, name }) => {
593
+ async ({ cwd, model, name, prompt }) => {
536
594
  try {
595
+ if (desktopTasksEnabled) {
596
+ if (!prompt?.trim()) throw new Error("Desktop task creation requires the initial prompt. Use delegate_to_codex, or pass prompt to start_codex_thread. No task was created.");
597
+ return await delegateDesktopTask({ cwd, model, name, prompt, waitForReply: false });
598
+ }
599
+ if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
537
600
  const created = await createCodexThread({ cwd, model, name });
538
601
  return textResult(
539
602
  [
@@ -567,6 +630,11 @@ server.registerTool(
567
630
  },
568
631
  async ({ threadId, limit }) => {
569
632
  try {
633
+ if (desktopTasksEnabled) {
634
+ await desktopTasks.inspect(threadId);
635
+ const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) });
636
+ return textResult(JSON.stringify(response, null, 2));
637
+ }
570
638
  await assertThreadAccess(threadId);
571
639
  const res = await client.call("thread/read", { threadId, includeTurns: true });
572
640
  const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
@@ -610,6 +678,10 @@ server.registerTool(
610
678
  },
611
679
  async ({ threadId, turnId }) => {
612
680
  try {
681
+ if (desktopTasksEnabled) {
682
+ await desktopTasks.inspect(threadId);
683
+ return textResult("This task is owned by Codex Desktop. Use its Stop button; the separate app-server cannot interrupt a Desktop turn.", true);
684
+ }
613
685
  await assertThreadAccess(threadId);
614
686
  const res = await client.call("thread/read", { threadId });
615
687
  const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
@@ -645,6 +717,12 @@ server.registerTool(
645
717
  },
646
718
  async ({ threadId, background }) => {
647
719
  try {
720
+ if (desktopTasksEnabled) {
721
+ await desktopTasks.inspect(threadId);
722
+ if (background) await openThreadInCodexApp(threadId, { activate: false });
723
+ else await desktopTasks.open(threadId);
724
+ return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
725
+ }
648
726
  await assertThreadAccess(threadId);
649
727
  const res = await client.call("thread/read", { threadId });
650
728
  const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
@@ -726,6 +804,7 @@ server.registerTool(
726
804
  `autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
727
805
  `desktop links: ${supportsCodexThreadLinks() ? "codex:// available" : "not available on this platform"}`,
728
806
  `security: thread policy ${security.threadPolicy} (${summary.allowAllThreads ? "all threads" : `${summary.authorizedThreads} pre-authorized thread(s)`}), ${summary.allowAllRoots ? "all directories" : `${summary.allowedRoots.length} allowed root(s)`}, sandbox ${security.sandbox}, approvals ${security.approvalPolicy}`,
807
+ `desktop tasks: ${desktopTasksEnabled ? "enabled; Desktop permissions, exact saved project, immediate visibility" : "disabled; app-server permissions (enable CODEX_BRIDGE_DESKTOP_TASKS=1 to use Desktop permissions)"}`,
729
808
  `live threads: ${liveThreads ?? "(unknown)"}`,
730
809
  `claude desktop config: ${claudeDesktopConfigPath()}`,
731
810
  ];
@@ -14,10 +14,13 @@ import {
14
14
  RELAY_PROTOCOL_VERSION,
15
15
  relaySocketPath,
16
16
  resolveRelayThreadId,
17
+ desktopTaskSocketPath,
18
+ validateDesktopOperation,
19
+ decodeNativeToolResult,
17
20
  } from "./native-relay.mjs";
18
21
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
19
22
 
20
- const VERSION = "1.12.4";
23
+ const VERSION = "1.13.0";
21
24
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
22
25
 
23
26
  function errorResponse(code, message) {
@@ -40,8 +43,11 @@ function errorCode(err) {
40
43
  */
41
44
  export async function handleRelayRequest(
42
45
  payload,
43
- { dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
46
+ { dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
44
47
  ) {
48
+ if (payload && typeof payload === "object" && Object.hasOwn(payload, "operation")) {
49
+ return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env });
50
+ }
45
51
  if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
46
52
  Object.keys(payload).some((key) => !["v", "targetThreadId", "message"].includes(key)) ||
47
53
  (payload.v !== undefined && payload.v !== RELAY_PROTOCOL_VERSION)) {
@@ -85,6 +91,37 @@ export async function handleRelayRequest(
85
91
  }
86
92
  }
87
93
 
94
+ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env }) {
95
+ if (Array.isArray(payload) || payload.v !== RELAY_PROTOCOL_VERSION ||
96
+ Object.keys(payload).some((key) => !["v", "operation", "arguments"].includes(key))) {
97
+ return errorResponse("RELAY_BAD_REQUEST", "expected an allowlisted Desktop operation");
98
+ }
99
+ try {
100
+ validateDesktopOperation(payload.operation, payload.arguments);
101
+ if (typeof dispatchDesktop !== "function") {
102
+ return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
103
+ }
104
+ const executorThreadId = resolveExecutor(env).threadId;
105
+ if (payload.operation === "send_message_to_thread" && payload.arguments.threadId === executorThreadId) {
106
+ return errorResponse("RELAY_BAD_REQUEST", "The relay executor cannot receive its own relayed message");
107
+ }
108
+ const nativeResult = await dispatchDesktop({
109
+ executorThreadId,
110
+ operation: payload.operation,
111
+ arguments: payload.arguments,
112
+ });
113
+ return {
114
+ ok: true,
115
+ v: RELAY_PROTOCOL_VERSION,
116
+ operation: payload.operation,
117
+ executorThreadId,
118
+ result: decodeNativeToolResult(nativeResult),
119
+ };
120
+ } catch (err) {
121
+ return errorResponse(errorCode(err), err?.message ?? String(err));
122
+ }
123
+ }
124
+
88
125
  /**
89
126
  * Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
90
127
  *
@@ -95,6 +132,7 @@ export class RelaySocketServer {
95
132
  constructor({
96
133
  socketPath,
97
134
  dispatch,
135
+ dispatchDesktop,
98
136
  resolveExecutor = resolveRelayThreadId,
99
137
  restrictSocket = (target) => {
100
138
  if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
@@ -103,6 +141,7 @@ export class RelaySocketServer {
103
141
  } = {}) {
104
142
  this.socketPath = socketPath;
105
143
  this.dispatch = dispatch;
144
+ this.dispatchDesktop = dispatchDesktop;
106
145
  this.resolveExecutor = resolveExecutor;
107
146
  this.restrictSocket = restrictSocket;
108
147
  this.log = logFn;
@@ -238,10 +277,11 @@ export class RelaySocketServer {
238
277
  }
239
278
  const response = await handleRelayRequest(payload, {
240
279
  dispatch: this.dispatch,
280
+ dispatchDesktop: this.dispatchDesktop,
241
281
  resolveExecutor: this.resolveExecutor,
242
282
  });
243
283
  if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
244
- else this.log(`relayed a message into thread ${response.targetThreadId}`);
284
+ else this.log(response.operation ? `completed Desktop operation ${response.operation}` : `relayed a message into thread ${response.targetThreadId}`);
245
285
  this.#reply(socket, response);
246
286
  }
247
287
 
@@ -338,7 +378,17 @@ if (invokedDirectly) {
338
378
  const nativeTools = new NativeToolsClient();
339
379
  const dispatch = (args) => nativeTools.dispatch(args);
340
380
 
341
- const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
381
+ const relay = new RelaySocketServer({
382
+ socketPath: relaySocketPath(),
383
+ dispatch,
384
+ dispatchDesktop: (args) => nativeTools.dispatchDesktop(args),
385
+ log,
386
+ });
387
+ const desktopRelay = desktopTaskSocketPath() === relay.socketPath ? relay : new RelaySocketServer({
388
+ socketPath: desktopTaskSocketPath(),
389
+ dispatchDesktop: (args) => nativeTools.dispatchDesktop(args),
390
+ log,
391
+ });
342
392
 
343
393
  mcp.registerTool(
344
394
  "native_relay_status",
@@ -370,6 +420,7 @@ if (invokedDirectly) {
370
420
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
371
421
  `companion: codex-native-relay ${VERSION}`,
372
422
  `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
423
+ `desktop tasks: ${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`,
373
424
  `executor: ${executor}`,
374
425
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
375
426
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
@@ -381,7 +432,8 @@ if (invokedDirectly) {
381
432
  );
382
433
 
383
434
  const startup = startRelayWhenAvailable({ nativeTools, relay, log });
384
- mcp.server.onclose = () => startup.stop();
435
+ const desktopStartup = desktopRelay === relay ? startup : startRelayWhenAvailable({ nativeTools, relay: desktopRelay, log });
436
+ mcp.server.onclose = () => { startup.stop(); desktopStartup.stop(); };
385
437
  await mcp.connect(new StdioServerTransport());
386
438
  log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
387
439
  }
@@ -66,6 +66,16 @@ export function relaySocketPath(env = process.env) {
66
66
  return env.CODEX_NATIVE_RELAY_SOCKET ?? (IS_WINDOWS ? WINDOWS_RELAY_SOCKET : path.join(codexHome(env), RELAY_SOCKET_NAME));
67
67
  }
68
68
 
69
+ export function desktopTaskSocketPath(env = process.env) {
70
+ return env.CODEX_NATIVE_RELAY_SOCKET ?? `${relaySocketPath(env)}-desktop-tasks`;
71
+ }
72
+
73
+ export function desktopTasksConfigured(env = process.env) {
74
+ return env.CODEX_BRIDGE_DESKTOP_TASKS !== undefined
75
+ ? env.CODEX_BRIDGE_DESKTOP_TASKS === "1"
76
+ : readRelayConfig(env)?.desktopTasks === true;
77
+ }
78
+
69
79
  export function relayConfigPath(env = process.env) {
70
80
  return path.join(codexHome(env), RELAY_CONFIG_NAME);
71
81
  }
@@ -141,6 +151,108 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
141
151
  };
142
152
  }
143
153
 
154
+ const DESKTOP_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
155
+
156
+ function exactObject(value, keys) {
157
+ return value !== null && typeof value === "object" && !Array.isArray(value) &&
158
+ Object.keys(value).every((key) => keys.includes(key));
159
+ }
160
+
161
+ function nonempty(value) {
162
+ return typeof value === "string" && value.trim().length > 0;
163
+ }
164
+
165
+ function optionalText(value) {
166
+ return value === undefined || nonempty(value);
167
+ }
168
+
169
+ function optionalInteger(value, min, max) {
170
+ return value === undefined || Number.isSafeInteger(value) && value >= min && value <= max;
171
+ }
172
+
173
+ export function validateDesktopOperation(operation, args) {
174
+ let valid = false;
175
+ const modelSettings = () => optionalText(args.model) &&
176
+ (args.thinking === undefined || DESKTOP_EFFORTS.has(args.thinking));
177
+ switch (operation) {
178
+ case "list_projects":
179
+ valid = exactObject(args, []);
180
+ break;
181
+ case "create_thread":
182
+ valid = exactObject(args, ["prompt", "target", "model", "thinking", "title"]) &&
183
+ nonempty(args.prompt) && optionalText(args.title) && modelSettings() &&
184
+ exactObject(args.target, ["type", "projectId", "environment"]) &&
185
+ args.target.type === "project" && nonempty(args.target.projectId) &&
186
+ exactObject(args.target.environment, ["type"]) && args.target.environment.type === "local";
187
+ break;
188
+ case "send_message_to_thread":
189
+ valid = exactObject(args, ["threadId", "prompt", "model", "thinking"]) &&
190
+ nonempty(args.threadId) && nonempty(args.prompt) && modelSettings();
191
+ break;
192
+ case "read_thread":
193
+ valid = exactObject(args, ["threadId", "hostId", "cursor", "turnLimit", "includeOutputs", "maxOutputCharsPerItem"]) &&
194
+ nonempty(args.threadId) && (args.hostId === undefined || args.hostId === "local") &&
195
+ optionalText(args.cursor) && optionalInteger(args.turnLimit, 1, 10) &&
196
+ (args.includeOutputs === undefined || typeof args.includeOutputs === "boolean") &&
197
+ optionalInteger(args.maxOutputCharsPerItem, 1, 16000);
198
+ break;
199
+ case "wait_threads":
200
+ valid = exactObject(args, ["targets", "timeoutMs"]) && args.timeoutMs === 0 &&
201
+ Array.isArray(args.targets) && args.targets.length >= 1 && args.targets.length <= 8 &&
202
+ args.targets.every((target) => exactObject(target, ["threadId", "hostId", "afterCursor"]) &&
203
+ nonempty(target.threadId) && (target.hostId === undefined || target.hostId === "local") &&
204
+ optionalText(target.afterCursor));
205
+ break;
206
+ case "navigate_to_codex_page":
207
+ valid = exactObject(args, ["threadId"]) && nonempty(args.threadId);
208
+ break;
209
+ case "set_thread_title":
210
+ valid = exactObject(args, ["threadId", "title"]) && nonempty(args.threadId) && nonempty(args.title);
211
+ break;
212
+ }
213
+ if (!valid) throw new NativeRelayError(`Unsupported or invalid Desktop operation: ${operation}`, "RELAY_BAD_REQUEST");
214
+ return args;
215
+ }
216
+
217
+ export function nativeDesktopOperationParams({ executorThreadId, operation, arguments: args }) {
218
+ validateDesktopOperation(operation, args);
219
+ return {
220
+ arguments: args,
221
+ callId: `codex-native-relay-${randomUUID()}`,
222
+ namespace: "codex_app",
223
+ threadId: executorThreadId,
224
+ tool: operation,
225
+ turnId: `codex-native-relay-turn-${randomUUID()}`,
226
+ };
227
+ }
228
+
229
+ export function decodeNativeToolResult(result) {
230
+ if (result?.success !== true || result?.isError === true) {
231
+ const detail = (result?.contentItems ?? result?.content ?? []).filter((item) => typeof item?.text === "string").map((item) => item.text).join("\n").slice(0, 2000);
232
+ throw new NativeRelayError(detail || "Codex Desktop did not confirm the requested operation", "NATIVE_DISPATCH_FAILED");
233
+ }
234
+ let decoded = result.structuredContent;
235
+ if (decoded === undefined) {
236
+ const items = result.contentItems ?? result.content;
237
+ if (Array.isArray(items)) {
238
+ const text = items.filter((item) => item?.type === "inputText" || item?.type === "text")
239
+ .map((item) => item.text).filter((value) => typeof value === "string").join("\n");
240
+ if (text) {
241
+ try {
242
+ decoded = JSON.parse(text);
243
+ } catch {
244
+ decoded = { text };
245
+ }
246
+ }
247
+ }
248
+ }
249
+ decoded ??= result;
250
+ if (decoded?.isError === true || decoded?.success === false) {
251
+ throw new NativeRelayError("Codex Desktop rejected the requested operation", "NATIVE_DISPATCH_FAILED");
252
+ }
253
+ return decoded;
254
+ }
255
+
144
256
  const execFileAsync = promisify(execFile);
145
257
 
146
258
  function splitDesktopCommandLine(commandLine, platform) {
@@ -389,12 +501,20 @@ export class NativeToolsClient {
389
501
  }
390
502
 
391
503
  async dispatch(args) {
504
+ return this.#request(nativeDispatchParams(args));
505
+ }
506
+
507
+ async dispatchDesktop(args) {
508
+ return this.#request(nativeDesktopOperationParams(args));
509
+ }
510
+
511
+ async #request(params) {
392
512
  const id = this.nextId++;
393
513
  const payload = Buffer.from(JSON.stringify({
394
514
  jsonrpc: "2.0",
395
515
  id,
396
516
  method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
397
- params: nativeDispatchParams(args),
517
+ params,
398
518
  }));
399
519
  if (payload.length > MAX_FRAME_BYTES) {
400
520
  throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
@@ -503,6 +623,15 @@ export class NativeDesktopRelay {
503
623
 
504
624
  async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
505
625
  const request = { v: RELAY_PROTOCOL_VERSION, targetThreadId, message };
626
+ return this.#request(request, timeoutMs);
627
+ }
628
+
629
+ async requestDesktop(operation, args, { timeoutMs = this.timeoutMs } = {}) {
630
+ validateDesktopOperation(operation, args);
631
+ return this.#request({ v: RELAY_PROTOCOL_VERSION, operation, arguments: args }, timeoutMs);
632
+ }
633
+
634
+ async #request(request, timeoutMs) {
506
635
  const line = `${JSON.stringify(request)}\n`;
507
636
  if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
508
637
  throw new NativeRelayError(
@@ -512,7 +641,10 @@ export class NativeDesktopRelay {
512
641
  }
513
642
 
514
643
  const response = await this.#roundTrip(line, timeoutMs);
515
- if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) return response;
644
+ if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) {
645
+ if (request.operation && response.operation !== request.operation) throw new NativeRelayError("The relay answered a different Desktop operation; do not retry this request", "RELAY_BAD_RESPONSE", { reachedCompanion: true });
646
+ return response;
647
+ }
516
648
  throw new NativeRelayError(
517
649
  response?.error?.message ?? "the Codex Desktop relay refused the message",
518
650
  response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
@@ -613,7 +745,7 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
613
745
  try {
614
746
  await client.call("thread/name/set", { threadId, name });
615
747
  } catch {}
616
- writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
748
+ writeRelayConfig({ ...readRelayConfig(env), relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
617
749
  } finally {
618
750
  release = await client.releaseThread(threadId);
619
751
  }
@@ -1,5 +1,7 @@
1
- import { NativeDesktopRelay } from "./native-relay.mjs";
1
+ import { NativeDesktopRelay, desktopTaskSocketPath } from "./native-relay.mjs";
2
2
  import { runTurn } from "./turn.mjs";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
3
5
 
4
6
  /**
5
7
  * Which backend puts a message into a Codex thread.
@@ -19,6 +21,124 @@ export const NATIVE_BACKEND = "codex-desktop-native";
19
21
  export const APP_SERVER_BACKEND = "app-server";
20
22
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
21
23
 
24
+ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
25
+ const requested = canonicalize(cwd);
26
+ const matches = projects.filter((project) => {
27
+ if (project.projectKind !== "local" || project.hostId !== "local" || !project.path || !project.projectId) return false;
28
+ try {
29
+ return paths.relative(requested, canonicalize(project.path)) === "";
30
+ } catch {
31
+ return false;
32
+ }
33
+ });
34
+ if (matches.length !== 1) {
35
+ throw new Error(matches.length
36
+ ? `Multiple Codex Desktop projects match ${cwd}; keep one saved project for this directory before delegating.`
37
+ : `No saved local Codex Desktop project exactly matches ${cwd}. Add that directory as a project in Codex Desktop first.`);
38
+ }
39
+ return matches[0];
40
+ }
41
+
42
+ export class DesktopTaskDelivery {
43
+ constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now } = {}) {
44
+ this.relay = relay;
45
+ this.security = security;
46
+ this.sleep = sleep;
47
+ this.now = now;
48
+ }
49
+
50
+ async request(operation, args) {
51
+ const response = await this.relay.requestDesktop(operation, args);
52
+ return response.result;
53
+ }
54
+
55
+ async create({ cwd, prompt, name, model, effort }) {
56
+ this.security.assertCwd(cwd);
57
+ const listed = await this.request("list_projects", {});
58
+ if (!Array.isArray(listed?.projects)) throw new Error("Codex Desktop returned no project list; no task was created.");
59
+ const project = matchDesktopProject(listed.projects, cwd);
60
+ const response = await this.request("create_thread", {
61
+ prompt,
62
+ title: name,
63
+ target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
64
+ ...(model ? { model } : {}),
65
+ ...(effort ? { thinking: effort } : {}),
66
+ });
67
+ const threadId = response?.threadId ?? response?.conversationId;
68
+ if (!threadId || response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown") {
69
+ throw new Error(`Desktop creation is not confirmed. Do not resend the prompt: ${JSON.stringify(response)}`);
70
+ }
71
+ this.security.registerThread(threadId);
72
+ if (response.hostId !== "local" || (response.firstTurn && response.firstTurn.status !== "accepted")) {
73
+ throw new Error(`Desktop created task ${threadId}, but did not confirm a local running turn: ${JSON.stringify(response)}. Inspect this task before retrying.`);
74
+ }
75
+ return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
76
+ }
77
+
78
+ async inspect(threadId, cwd) {
79
+ const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 });
80
+ const thread = response?.thread;
81
+ if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
82
+ this.security.assertThread(threadId, thread.cwd);
83
+ this.security.assertCwd(thread.cwd);
84
+ if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
85
+ throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
86
+ }
87
+ return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
88
+ }
89
+
90
+ async send({ threadId, prompt, cwd, model, effort, name }) {
91
+ const inspected = await this.inspect(threadId, cwd);
92
+ if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) });
93
+ const response = await this.request("send_message_to_thread", {
94
+ threadId, prompt,
95
+ ...(model ? { model } : {}),
96
+ ...(effort ? { thinking: effort } : {}),
97
+ });
98
+ if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
99
+ (response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
100
+ (response?.firstTurn && response.firstTurn.status !== "accepted")) {
101
+ throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
102
+ }
103
+ return { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
104
+ }
105
+
106
+ async open(threadId) {
107
+ const response = await this.request("navigate_to_codex_page", { threadId });
108
+ if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
109
+ }
110
+
111
+ async wait(threadId, { timeoutMs = 240000, previousTurnId = null } = {}) {
112
+ const startedAt = this.now();
113
+ let cursor;
114
+ let turnId = null;
115
+ let text = "";
116
+ for (;;) {
117
+ const response = await this.request("wait_threads", {
118
+ targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
119
+ });
120
+ const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
121
+ if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
122
+ cursor = poll.cursor;
123
+ const turn = poll.latestTurn;
124
+ const threadStatus = poll.thread.status?.type;
125
+ if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
126
+ return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
127
+ }
128
+ if (turn?.id && turn.id !== previousTurnId) {
129
+ turnId = turn.id;
130
+ if (poll.latestAssistantMessage?.turnId === turnId && poll.latestAssistantMessage?.phase === "final_answer") text = poll.latestAssistantMessage.text ?? text;
131
+ const status = turn.status;
132
+ if (RELEASE_STATUSES.has(status)) {
133
+ return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
134
+ }
135
+ }
136
+ if (this.now() - startedAt >= timeoutMs) return { threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt };
137
+ await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
138
+ }
139
+ }
140
+ }
141
+
22
142
  export function createThreadDelivery({
23
143
  codex,
24
144
  relay = new NativeDesktopRelay(),