SLOPSHOPPER

Codex Computer Use

Claude thinks, Codex clicks: routes Claude's desktop computer use through Codex's computer-use engine (exact values via UI Automation; background on macOS…

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-computer-use
│ ┃ codex-approval ✕ › fix the failing auth test and add an audit log call │ ┃ No approval waiting. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /codex-cu │ ⎿ codex-computer-use: Codex computer use: On. Desktop apps go thro │ ⎿ codex-computer-use: Auto-approve: off. │ ⎿ codex-computer-use: No apps allowed yet in this chat. │ ⎿ codex-computer-use: Always allowed: none (revoke with /codex-cu │ ⎿ codex-computer-use: Bridge: not running (it starts on first use) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ codex-computer-use: Codex CU on

Draws

Pane · codex-approval
No approval waiting.
README

Codex Computer Use

Claude thinks, Codex clicks. Claude Code's desktop computer use, routed through the computer-use engine inside OpenAI's Codex app. Codex reads apps through the operating system's accessibility layer (on Windows, UI Automation), so Claude works with the exact values in a spreadsheet cell or a form field, not a guess from a screenshot.

Ask Claude to do something in a desktop app, for example:

"Open messy-leads.xlsx on my Desktop in Excel. Remove the duplicate leads, make the names proper case, put the phone numbers in one format, sort by date and highlight the leads with no phone."

Claude plans each step; Codex reads the window and acts. You approve each app once.

What you see

  • An approval pane the first time Claude wants an app: Allow for this chat, Always allow, or Deny. Nothing is approved to start with, and auto-approve is off.
  • A status line while the bridge is running.
  • Claude's own computer-use tools are switched off while this is on, so every action goes through Codex and your approvals.

Commands

TypeWhat happens
/codex-cu statusIs it on, is the bridge running, which apps are approved
/codex-cu on · /codex-cu offRoute computer use through Codex, or give Claude its own tools back
/codex-cu allow <app> · always <app> · deny <app>Approve an app for this chat, for good, or refuse it
/codex-cu forget <app> (or all)Remove an app from the always-allowed list
/codex-cu auto on · auto offApprove every app without asking (off by default; leave it off unless you know why)

Needs

  • Claude Code 2.1.287 or newer (2.1.286+ in the desktop app), with mods on.
  • Node to run the small bridge script. Nothing to install: it uses the Node that ships inside the Codex app (Windows) or the ChatGPT app (macOS), and falls back to node on your PATH.
  • Windows: the Codex app installed, signed in, with computer use set up. The mod finds Codex's engine through ~/.codex/config.toml, so it keeps working after Codex updates.
  • macOS: the ChatGPT Mac app at /Applications/ChatGPT.app with Codex computer use set up.

The computer-use engine itself is OpenAI's and is not included. Your Codex/ChatGPT plan and terms apply.

Good to know

  • On Windows it takes over the screen. Codex brings the app to the front and uses the real mouse and keyboard while it acts. Leave the machine alone during a run. (On macOS it can work in the background.)
  • Codex's own limits apply. It will not drive terminals, the Run dialog, password managers, security apps, sign-in screens or the Codex/ChatGPT app itself.
  • Pop-up dialogs (Excel's Remove Duplicates, Save As) sometimes don't show in the accessibility tree; Claude takes a screenshot to see where the focus is before pressing Enter.
  • One Codex session per Claude chat; two chats can't drive the same app at once.

What it reaches

NetworkRuns processesFilesCalls a modelSends data anywhere
Only 127.0.0.1 (its own bridge)Starts the bridge (bridge/start.mjs) with Node~/.codex/config.toml (read), ~/.claude/codex-cu/ (bridge state, always-allowed apps)NoWindow contents go to Codex's local engine; what Claude reads goes to Claude, as with any tool

The bridge listens on 127.0.0.1 only and rejects any request without the random token it writes to ~/.claude/codex-cu/daemon.json.

Credits

Based on the Computer Use mod from Prompt Advisers' Two mods for Claude Code (MIT, see LICENSE), which targeted macOS. This version adds Windows support. What changed is in CHANGES.md.

Source 2 files
hooks/register.tsx 394 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CodexCuPending } from '../types'
5
6// Codex's computer-use engine (node_repl + @oai/sky on Windows, cua_repl on macOS) drives apps in
7// the background on macOS; on Windows it reads apps through UI Automation but takes the foreground to act. The
8// mod reaches it through a small daemon (bridge/daemon.mjs, shipped in this plugin) that keeps one
9// engine per Claude session. Codex asks before using each app; the daemon answers yes only for
10// apps the person allowed in the pane below. The daemon listens on 127.0.0.1 and requires the
11// token it writes to <home>/.claude/codex-cu/daemon.json.
12const BRIDGE = 'mcp__codex-computer-use__cua'
13const BRIDGE_RESET = 'mcp__codex-computer-use__cua_reset'
14const OWN_COMPUTER_USE = /^mcp__(computer-use__|remote-devices__computer)/
15const MAC_NODE = '/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node'
16const PANE = 'codex-approval'
17const DAEMON_VERSION = 'v3'
18
19type Host = { home: string; data: string; isWindows: boolean }
20type Daemon = { url: string; token: string }
21type Standing = { apps: string[]; autoApproveAll: boolean }
22
23const alwaysFile = (host: Host) => `${host.data}/always-allowed.json`
24
25async function readStanding($: EngineInterface, host: Host): Promise<Standing> {
26  try {
27    const parsed = JSON.parse(String(await $.fs.read(alwaysFile(host))))
28    return { apps: Array.isArray(parsed.apps) ? parsed.apps : [], autoApproveAll: parsed.autoApproveAll === true }
29  } catch {
30    return { apps: [], autoApproveAll: false }
31  }
32}
33
34async function writeStanding($: EngineInterface, host: Host, change: Partial<Standing>) {
35  const standing = await readStanding($, host)
36  await $.fs.write(alwaysFile(host), `${JSON.stringify({ ...standing, ...change }, null, 2)}\n`)
37}
38
39const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
40
41async function daemonInfo($: EngineInterface, host: Host): Promise<Daemon | null> {
42  try {
43    const state = JSON.parse(String(await $.fs.read(`${host.data}/daemon.json`)))
44    return { url: `http://127.0.0.1:${state.port}`, token: String(state.token) }
45  } catch {
46    return null
47  }
48}
49
50async function daemonFetch($: EngineInterface, daemon: Daemon, path: string, body?: unknown) {
51  return $.http.fetch(`${daemon.url}${path}`, {
52    method: body === undefined ? 'GET' : 'POST',
53    headers: { 'x-codex-cu-token': daemon.token },
54    body: body === undefined ? undefined : JSON.stringify(body),
55  })
56}
57
58async function healthOf($: EngineInterface, daemon: Daemon | null): Promise<string | null> {
59  if (daemon === null) return null
60  try {
61    const res = await daemonFetch($, daemon, '/health')
62    return res.ok ? res.text.trim() : null
63  } catch {
64    return null
65  }
66}
67
68// Node to run the bridge with: the one Codex's current runtime ships (read fresh from Codex's own
69// config, so a Codex update never leaves it pointing at an old folder), else node on PATH.
70async function nodeCandidates($: EngineInterface, host: Host): Promise<string[]> {
71  const found: string[] = []
72  if (host.isWindows) {
73    try {
74      const toml = String(await $.fs.read(`${host.home}/.codex/config.toml`))
75      const path = /NODE_REPL_NODE_PATH\s*=\s*(?:'([^']+)'|"([^"]+)")/.exec(toml)
76      const node = path?.[1] ?? path?.[2]?.replace(/\\\\/g, '\\')
77      if (node) found.push(node)
78    } catch {
79      // no Codex config: node on PATH below
80    }
81  } else {
82    found.push(MAC_NODE)
83  }
84  return [...found, 'node']
85}
86
87async function ensureDaemon($: EngineInterface, host: Host): Promise<Daemon | string> {
88  let daemon = await daemonInfo($, host)
89  const health = await healthOf($, daemon)
90  if (daemon !== null && health === `ok ${DAEMON_VERSION}`) return daemon
91  // an older daemon speaks another protocol: ask it to leave
92  if (daemon !== null && health !== null) {
93    try {
94      await daemonFetch($, daemon, '/quit', {})
95    } catch {
96      // already gone
97    }
98    await $.clock.sleep(300)
99  }
100
101  let started = ''
102  for (const node of await nodeCandidates($, host)) {
103    try {
104      const ran = await $.process.run([node, `${$.plugin.root}/bridge/start.mjs`, host.data], { cwd: host.home })
105      if (ran.exitCode === 0) {
106        started = node
107        break
108      }
109    } catch {
110      // this node could not start: try the next
111    }
112  }
113  if (started === '') return `Could not start the Codex computer-use bridge: no working Node found (Codex runtime or node on PATH). Is the Codex app installed?`
114
115  for (let i = 0; i < 40; i++) {
116    await $.clock.sleep(250)
117    daemon = await daemonInfo($, host)
118    if ((await healthOf($, daemon)) === `ok ${DAEMON_VERSION}` && daemon !== null) return daemon
119  }
120  return `The Codex computer-use bridge did not start. See ${host.data}/daemon.log.`
121}
122
123const pending = atom({ plugin: 'codex-computer-use', key: 'pending' } as const, null as CodexCuPending)
124const allowed = atom({ plugin: 'codex-computer-use', key: 'allowed' } as const, [] as string[])
125
126const WINDOWS_ROUTING = `# Desktop apps go through Codex computer use (codex-cu mod is on)
127
128For any task that needs to see or operate a desktop app on this Windows PC, use Codex's Windows computer-use engine through \`${BRIDGE}\` (reset with \`${BRIDGE_RESET}\`; load them with ToolSearch if they are deferred). Your own mcp__computer-use__* tools are blocked while this mode is on. The point of this mode: Codex reads apps through Windows UI Automation (exact values, not screenshots) and asks the person before using each app. On Windows it is not background automation: typing and clicks bring the app to the front and take over input while they run, so tell the person to leave the machine alone during a run.
129
130How to drive it:
131- The tool runs JavaScript in a persistent node_repl. Print with \`nodeRepl.write(string)\` (JSON.stringify objects). Keep values across calls on \`globalThis\`; top-level const/let cannot be redeclared by a later call.
132- First call (or after a reset): \`if (!globalThis.sky) { const { sky } = await import("@oai/sky"); globalThis.sky = sky; }\` then \`globalThis.apps = await sky.list_apps();\` and print the id, displayName and window count of the apps you need.
133- Use the app \`id\` list_apps returned (Calculator is \`Microsoft.WindowsCalculator_8wekyb3d8bbwe!App\`). If it has no window: \`await sky.launch_app({ app: id })\`, wait about 1.5 s, call list_apps again and take the returned window object. Never build a window object yourself.
134- Observe: \`globalThis.state = await sky.get_window_state({ window, include_screenshot: false, include_text: true })\`, then print \`state.accessibility.tree\` (filter its lines when it is long). \`document_text\`, \`focused_element\` and \`selected_text\` are on \`state.accessibility\` too. Element indexes are valid only for the latest state: observe again after every action that changes the UI.
135- Act mostly by keyboard: \`sky.press_key({ window, key: "Control_L+b" })\` (X keysym names: Return, Tab, Escape, Up, Down, Alt_L, Control_L+a, Control_L+Home, KP_0..KP_9; press ribbon KeyTips one key per call, e.g. Alt_L then a then m) and \`sky.type_text({ window, text })\` once the focus is confirmed. \`sky.set_value({ window, element_index, value })\` sets an edit field directly (in Excel, set the Name Box to a range like "A1:F1" and press Return instead of using Go To). \`sky.click({ window, element_index })\` can fail with "geometry unavailable" or "not in cached state"; fall back to the keyboard. Coordinate clicks (\`sky.click({ window, screenshotId, x, y })\`) move the person's real cursor: use them only when nothing else works.
136- Pop-up dialogs (Excel's Remove Duplicates, Greater Than, Save As) often do not appear in the accessibility tree or in list_windows. Take a screenshot to see where the keyboard focus is before pressing Return (Return presses the focused button, not always OK), and if keys stop having any effect, the dialog has lost the foreground: \`sky.activate_window({ window })\` first.
137- Screenshots: pass \`include_screenshot: true\` only when the accessibility tree is not enough; the result names a saved image you can open with Read.
138- After an error or a surprise, observe before retrying: an action can fail yet still apply, and a dialog may have opened (find it with \`sky.list_windows()\`).
139- Each app needs the person's approval. If the result says they are being asked, stop and wait for their answer; a message will say what they chose. Never try to get around a denial.
140- Codex's own limits apply: never automate terminals, the Run dialog, password managers, security apps, sign-in dialogs or the Codex/ChatGPT app, and never press the Windows key.
141- Other Claude sessions may use computer use at the same time; an app one of them is driving is leased to it. If told an app is in use, use another app or wait about a minute.
142- Prefer purpose-built tools first (a CLI, an API, a connector, the browser tools for web pages). Use this for native apps and anything only the GUI can do. Say where the result is when you finish.`
143
144const MAC_ROUTING = `# Desktop apps go through Codex computer use (codex-cu mod is on)
145
146For any task that needs to see or operate a desktop app on this Mac, use Codex's computer-use engine through \`${BRIDGE}\` (reset with \`${BRIDGE_RESET}\`). The mcp__computer-use__* tools are blocked while this mode is on.
147
148- It runs JavaScript in a persistent REPL with a \`cua\` object. First call (or after a reset): exactly one entry call, such as \`let app = await cua.getApp("Calculator");\`, and nothing else. Its result carries the API documentation and the app's accessibility tree; use only documented APIs.
149- Prefer element indices from the accessibility tree over coordinates, and keyboard shortcuts where the app has them. Re-read indices after every action.
150- It works in the background without moving the person's cursor.
151- Each app needs approval. If the result says the person is being asked, stop and wait for their answer. Never get around a denial.`
152
153// Records the person's answer to an app approval and closes the pane; resolves the message that
154// tells Claude what they chose.
155async function grant($: EngineInterface, host: Host, app: string, choice: 'session' | 'always' | 'deny'): Promise<string> {
156  if (choice !== 'deny') {
157    if (choice === 'always') {
158      const standing = await readStanding($, host)
159      if (!standing.apps.some(one => same(one, app))) await writeStanding($, host, { apps: [...standing.apps, app] })
160    }
161    await update($, allowed, list => (list.some(one => same(one, app)) ? list : [...list, app]))
162  }
163  await update($, pending, () => null)
164  try {
165    await $.ui.close({ id: PANE })
166  } catch {
167    // the pane was not open
168  }
169  return choice === 'session'
170    ? `I allowed Codex computer use to use ${app} for this chat. Retry the last call and continue the task.`
171    : choice === 'always'
172      ? `I always allowed Codex computer use to use ${app}. Retry the last call and continue the task.`
173      : `I did not allow Codex computer use to use ${app}. Do not use ${app}; tell me another way or stop.`
174}
175
176// Hands the person's answer to Claude as their message. A command hook cannot submit while it
177// holds the turn, so it calls this without awaiting and the message enters once the command is done.
178async function resume($: EngineInterface, text: string) {
179  try {
180    await $.clock.sleep(150)
181    await $.prompt.submit({ text, asUser: true })
182  } catch (error) {
183    $.ui.toast(`codex-cu: could not tell Claude (${String(error).slice(0, 100)}). Say "continue".`)
184  }
185}
186
187export const register: Register = on => {
188  let isOn = true
189  let host: Host = { home: '', data: '', isWindows: false }
190
191  on('session.start', async ($, e, next) => {
192    isOn = (await $.store.get('enabled')) !== false
193    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
194    const home = ((isWindows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
195    host = { home, data: `${home}/.claude/codex-cu`, isWindows }
196    try {
197      await $.command.register({
198        name: 'codex-cu',
199        description: 'Codex computer use: on, off, status, allow/always/deny <app>, auto on/off, forget <app>',
200        argumentHint: '[on|off|status|allow <app>|always <app>|deny <app>|auto on|auto off|forget <app>]',
201        immediate: true,
202      })
203    } catch {
204      // offered as /codex-computer-use:codex-cu instead
205    }
206    $.ui.status(isOn ? 'Codex CU on' : undefined)
207    try {
208      await $.tool.register({
209        name: 'cua',
210        description: isWindows
211          ? "Codex computer use for Windows desktop apps (reads exact values through UI Automation; acting brings the app to the front). Runs JavaScript in Codex's persistent node_repl; import `sky` from \"@oai/sky\" and call sky.list_apps(), sky.launch_app(), sky.get_window_state(), sky.click({ window, element_index }), sky.set_value(), sky.press_key(), sky.type_text(). Print with nodeRepl.write(). Apps need the person's approval."
212          : "Codex computer use: control native Mac apps in the background. Runs JavaScript in Codex's persistent cua_repl with a `cua` object. First call: exactly one entry call, e.g. `let app = await cua.getApp(\"Calculator\");`. Apps need the person's approval.",
213        inputSchema: {
214          type: 'object',
215          properties: {
216            code: { type: 'string', description: 'JavaScript to run in the Codex computer-use REPL.' },
217            timeout_ms: { type: 'integer', minimum: 1, description: 'Execution timeout in milliseconds (default 30000).' },
218            title: { type: 'string', maxLength: 80, description: 'Short user-facing description of what the code does.' },
219          },
220          required: ['code'],
221        },
222      })
223      await $.tool.register({
224        name: 'cua_reset',
225        description: 'Reset the Codex computer-use JavaScript session. Does not close apps or windows.',
226      })
227    } catch (error) {
228      $.ui.toast(`codex-cu: tool not registered (${String(error).slice(0, 120)})`)
229    }
230
231    return next(e)
232  })
233
234  on('tool.call', { tool: [BRIDGE, BRIDGE_RESET] }, async ($, e) => {
235    const { tool, tool_use_id, agentId, ...args } = e as typeof e & { agentId?: string }
236    const fail = (text: string) => ({ result: text, text, isError: true as const })
237    if (!isOn) {
238      return fail('codex-cu is off, so desktop computer use goes through your own computer-use tools (mcp__computer-use__*). The person can switch Codex back on with /codex-cu on.')
239    }
240    // its own Codex engine per Claude session, and per subagent inside it
241    const session = `${await $.session.id()}${agentId ? `/${agentId}` : ''}`
242    const isReset = tool === BRIDGE_RESET
243
244    const daemon = await ensureDaemon($, host)
245    if (typeof daemon === 'string') return fail(daemon)
246
247    const approve = await read($, allowed)
248    const res = await daemonFetch($, daemon, isReset ? '/reset' : '/js', isReset ? { session } : { ...args, approve, session })
249    const busy: { app: string; idleSeconds: number }[] = JSON.parse(res.headers['x-codex-busy'] ?? '[]')
250    if (busy.length > 0) {
251      const { app, idleSeconds } = busy[0]
252      return fail(
253        `${app} is being driven by another Claude session right now (its last call was ${idleSeconds}s ago), so it is leased to that session. Work in a different app, or wait about a minute and retry. Do not try to get around it.`,
254      )
255    }
256    const declined: string[] = JSON.parse(res.headers['x-codex-declined'] ?? '[]')
257    if (declined.length > 0) {
258      const app = declined[0]
259      await update($, pending, () => ({ app }))
260      let isShown = false
261      try {
262        isShown = (await $.ui.open({ id: PANE, title: 'Codex computer use', focus: true, closeOnEscape: true, rows: 6 })).isPlaced
263      } catch {
264        isShown = false
265      }
266      $.ui.toast(`Codex wants to use ${app}: allow for this chat, always allow, or don't allow`)
267      return fail(
268        `Codex needs the person's approval to use ${app}. They are being asked now${isShown ? ' in the Codex computer use panel' : ''} (they can also answer with /codex-cu allow ${app}, /codex-cu always ${app} or /codex-cu deny ${app}). Stop and wait: a message will say what they chose, and then you can retry the same call.`,
269      )
270    }
271
272    return res.ok ? { result: res.text, text: res.text } : fail(res.text || `The bridge answered ${res.status}.`)
273  })
274
275  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
276    const { Box, Button, Text } = $.ui.resolve(e)
277    const ask = await read($, pending)
278    if (ask === null) return <Text dimColor>No approval waiting.</Text>
279    const app = ask.app
280
281    return (
282      <Box flexDirection="column">
283        <Text>Allow Codex computer use to use {app}?</Text>
284        <Text dimColor>It sends real clicks and keystrokes to {app}; on Windows the app comes to the front while it works.</Text>
285        <Box>
286          <Button key="allow" label="Allow for this chat" hotkey="a" variant="primary" autoFocus onPress={async () => resume($, await grant($, host, app, 'session'))} />
287          <Text> </Text>
288          <Button key="always" label="Always allow" hotkey="l" onPress={async () => resume($, await grant($, host, app, 'always'))} />
289          <Text> </Text>
290          <Button key="deny" label="Don't allow" hotkey="d" role="dismiss" onPress={async () => resume($, await grant($, host, app, 'deny'))} />
291        </Box>
292      </Box>
293    )
294  })
295
296  on('command.run', { command: ['codex-cu', 'codex-computer-use:codex-cu'] }, async ($, e) => {
297    const raw = e.args.trim()
298    const arg = raw.toLowerCase()
299    const verb = arg.split(/\s+/)[0] ?? ''
300    const target = raw.slice(verb.length).trim()
301
302    if (verb === 'allow' || verb === 'always' || verb === 'deny') {
303      const ask = await read($, pending)
304      const app = target || ask?.app
305      if (!app) return { text: `Usage: /codex-cu ${verb} <app name>` }
306      void resume($, await grant($, host, app, verb === 'allow' ? 'session' : verb))
307      return { text: verb === 'deny' ? `Not allowed: ${app}.` : `Allowed${verb === 'always' ? ' always' : ' for this chat'}: ${app}.` }
308    }
309    if (arg === 'auto on' || arg === 'auto off') {
310      const isAuto = arg === 'auto on'
311      await writeStanding($, host, { autoApproveAll: isAuto })
312      return {
313        text: isAuto
314          ? "Auto-approve is on: Codex computer use may use any app without asking (Codex's own safety blocks still apply)."
315          : 'Auto-approve is off: apps not on the always-allowed list ask first.',
316      }
317    }
318    if (verb === 'forget') {
319      if (target === '') return { text: 'Usage: /codex-cu forget <app name> (or: all)' }
320      const always = (await readStanding($, host)).apps
321      const isAll = same(target, 'all')
322      const kept = isAll ? [] : always.filter(one => !same(one, target))
323      await writeStanding($, host, { apps: kept })
324      await update($, allowed, list => (isAll ? [] : list.filter(one => !same(one, target))))
325      return { text: kept.length === always.length && !isAll ? `${target} was not on the always-allowed list.` : `Removed. Always allowed now: ${kept.length > 0 ? kept.join(', ') : 'none'}.` }
326    }
327    if (arg === 'on' || arg === 'off') {
328      isOn = arg === 'on'
329      await $.store.set('enabled', isOn)
330      $.ui.status(isOn ? 'Codex CU on' : undefined)
331    } else if (arg !== '' && arg !== 'status') {
332      return { text: 'Usage: /codex-cu [on|off|status|allow <app>|always <app>|deny <app>|auto on|auto off|forget <app>]' }
333    }
334
335    const apps = await read($, allowed)
336    const standing = await readStanding($, host)
337    const mode = isOn
338      ? 'On. Desktop apps go through Codex (exact values from UI Automation, per-app approval); my own computer-use tools are blocked. On Windows the app comes to the front while Codex works.'
339      : 'Off. I use my own computer-use tools.'
340    const lines = [
341      `Codex computer use: ${mode}`,
342      standing.autoApproveAll ? 'Auto-approve: ON, every app is allowed without asking (/codex-cu auto off to ask again).' : 'Auto-approve: off.',
343      apps.length > 0 ? `Allowed this chat: ${apps.join(', ')}.` : 'No apps allowed yet in this chat.',
344      `Always allowed: ${standing.apps.length > 0 ? standing.apps.join(', ') : 'none'} (revoke with /codex-cu forget <app>).`,
345    ]
346    const daemon = await daemonInfo($, host)
347    if ((await healthOf($, daemon)) === null || daemon === null) {
348      lines.push('Bridge: not running (it starts on first use).')
349    } else {
350      try {
351        const me = await $.session.id()
352        const users: { session: string; busy: boolean; leases: string[]; idleSeconds: number }[] = JSON.parse((await daemonFetch($, daemon, '/sessions')).text)
353        lines.push(
354          users.length === 0
355            ? 'Bridge: running, no sessions using it.'
356            : `Bridge: ${users.map(u => `${u.session.startsWith(me) ? 'this chat' : u.session.slice(0, 8)}${u.busy ? ' working' : ` idle ${u.idleSeconds}s`}${u.leases.length > 0 ? `, holds ${u.leases.join(', ')}` : ''}`).join('; ')}.`,
357        )
358      } catch {
359        lines.push('Bridge: running.')
360      }
361    }
362    return { text: lines.join('\n') }
363  })
364
365  // a session that ends frees its Codex engines and app leases at once
366  on('session.end', async ($, e, next) => {
367    const done = await next(e)
368    try {
369      const daemon = await daemonInfo($, host)
370      if (daemon !== null) await daemonFetch($, daemon, '/end', { session: e.sessionId })
371    } catch {
372      // no bridge running: nothing to free
373    }
374    return done
375  })
376
377  on('prompt.compose', async ($, e, next) => {
378    const composed = await next(e)
379    if (!isOn) return composed
380    return {
381      ...composed,
382      sections: [...composed.sections, { id: 'codex-cu:routing', text: host.isWindows ? WINDOWS_ROUTING : MAC_ROUTING, scope: 'session' as const }],
383    }
384  })
385
386  on('tool.call', { tool: OWN_COMPUTER_USE }, ($, e, next) =>
387    isOn
388      ? {
389          deny: `codex-cu mode is on, so desktop computer use goes through Codex. Use ${BRIDGE} instead (load it with ToolSearch if needed). The person can switch back with /codex-cu off.`,
390        }
391      : next(e),
392  )
393}
394
types/index.d.ts 8 lines
1export type CodexCuPending = { app: string } | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'codex-computer-use': { pending: CodexCuPending; allowed: string[] }
6  }
7}
8