codex-ipc 0.1.0__tar.gz

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.
Files changed (50) hide show
  1. codex_ipc-0.1.0/.github/workflows/publish.yml +90 -0
  2. codex_ipc-0.1.0/.gitignore +13 -0
  3. codex_ipc-0.1.0/.python-version +1 -0
  4. codex_ipc-0.1.0/.vscode/launch.json +19 -0
  5. codex_ipc-0.1.0/PKG-INFO +62 -0
  6. codex_ipc-0.1.0/README.md +50 -0
  7. codex_ipc-0.1.0/examples/demo.py +451 -0
  8. codex_ipc-0.1.0/examples/tui.py +1055 -0
  9. codex_ipc-0.1.0/packages/codex-sdk/README.md +24 -0
  10. codex_ipc-0.1.0/packages/codex-sdk/pyproject.toml +34 -0
  11. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/__init__.py +112 -0
  12. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_inputs.py +63 -0
  13. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_message_router.py +158 -0
  14. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_run.py +112 -0
  15. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_version.py +37 -0
  16. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/api.py +764 -0
  17. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/async_client.py +214 -0
  18. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/client.py +582 -0
  19. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/errors.py +125 -0
  20. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/__init__.py +1 -0
  21. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/notification_registry.py +172 -0
  22. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/v2_all.py +8715 -0
  23. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/models.py +99 -0
  24. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/py.typed +0 -0
  25. codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/retry.py +41 -0
  26. codex_ipc-0.1.0/packages/ipc/README.md +17 -0
  27. codex_ipc-0.1.0/packages/ipc/pyproject.toml +34 -0
  28. codex_ipc-0.1.0/packages/ipc/src/ipc/__init__.py +22 -0
  29. codex_ipc-0.1.0/packages/ipc/src/ipc/client.py +459 -0
  30. codex_ipc-0.1.0/packages/ipc/src/ipc/constants.py +55 -0
  31. codex_ipc-0.1.0/packages/ipc/src/ipc/conversation_state.py +846 -0
  32. codex_ipc-0.1.0/packages/ipc/src/ipc/frame_protocol.py +84 -0
  33. codex_ipc-0.1.0/packages/ipc/src/ipc/immer.py +207 -0
  34. codex_ipc-0.1.0/packages/ipc/src/ipc/owner_follower.py +2651 -0
  35. codex_ipc-0.1.0/packages/ipc/src/ipc/py.typed +0 -0
  36. codex_ipc-0.1.0/packages/ipc/src/ipc/router.py +555 -0
  37. codex_ipc-0.1.0/pyproject.toml +29 -0
  38. codex_ipc-0.1.0/src/codex_ipc/__init__.py +28 -0
  39. codex_ipc-0.1.0/src/codex_ipc/codex/__init__.py +0 -0
  40. codex_ipc-0.1.0/src/codex_ipc/codex/api.py +285 -0
  41. codex_ipc-0.1.0/src/codex_ipc/codex/async_client.py +77 -0
  42. codex_ipc-0.1.0/src/codex_ipc/codex/client.py +245 -0
  43. codex_ipc-0.1.0/src/codex_ipc/codex/generated/__init__.py +0 -0
  44. codex_ipc-0.1.0/src/codex_ipc/codex/generated/v2_all.py +83 -0
  45. codex_ipc-0.1.0/src/codex_ipc/config.py +28 -0
  46. codex_ipc-0.1.0/src/codex_ipc/session.py +509 -0
  47. codex_ipc-0.1.0/src/codex_ipc/user_input.py +174 -0
  48. codex_ipc-0.1.0/tests/__init__.py +1 -0
  49. codex_ipc-0.1.0/tests/test_plan_mode_user_input.py +706 -0
  50. codex_ipc-0.1.0/uv.lock +352 -0
@@ -0,0 +1,90 @@
1
+ name: Python Package
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - main
8
+ tags:
9
+ - "v*"
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ test:
17
+ name: Test on ${{ matrix.os }}
18
+ runs-on: ${{ matrix.os }}
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ os:
23
+ - ubuntu-latest
24
+ - macos-latest
25
+ - windows-latest
26
+
27
+ steps:
28
+ - name: Check out repository
29
+ uses: actions/checkout@v4
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.12"
35
+
36
+ - name: Install uv
37
+ run: python -m pip install --upgrade uv
38
+
39
+ - name: Run test suite
40
+ run: uv run --with pytest pytest tests/ -q
41
+
42
+ build:
43
+ name: Build distributions
44
+ runs-on: ubuntu-latest
45
+ needs: test
46
+
47
+ steps:
48
+ - name: Check out repository
49
+ uses: actions/checkout@v4
50
+
51
+ - name: Set up Python
52
+ uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.12"
55
+
56
+ - name: Install uv
57
+ run: python -m pip install --upgrade uv
58
+
59
+ - name: Build package
60
+ run: uv build
61
+
62
+ - name: Check distributions
63
+ run: uvx twine check --strict dist/*
64
+
65
+ - name: Upload distributions
66
+ uses: actions/upload-artifact@v4
67
+ with:
68
+ name: python-package-distributions
69
+ path: dist/
70
+
71
+ publish-to-pypi:
72
+ name: Publish to PyPI
73
+ runs-on: ubuntu-latest
74
+ needs: build
75
+ if: startsWith(github.ref, 'refs/tags/v')
76
+ environment:
77
+ name: pypi
78
+ url: https://pypi.org/project/codex-ipc/
79
+ permissions:
80
+ id-token: write
81
+
82
+ steps:
83
+ - name: Download distributions
84
+ uses: actions/download-artifact@v4
85
+ with:
86
+ name: python-package-distributions
87
+ path: dist/
88
+
89
+ - name: Publish distributions to PyPI
90
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ tmp/
12
+ .codex
13
+ .zed/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,19 @@
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "name": "Demo",
9
+ "type": "debugpy",
10
+ "request": "launch",
11
+ "module": "examples.demo",
12
+ "cwd": "${workspaceFolder}",
13
+ "env": {
14
+ "PYTHONPATH": "${workspaceFolder}/src"
15
+ },
16
+ "console": "integratedTerminal"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-ipc
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.12
7
+ Provides-Extra: tui
8
+ Requires-Dist: pillow>=12.2.0; extra == 'tui'
9
+ Requires-Dist: rich>=15.0.0; extra == 'tui'
10
+ Requires-Dist: textual>=8.2.5; extra == 'tui'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # codex-ipc
14
+
15
+ Python facade for running Codex app-server sessions and sharing their live
16
+ conversation state over the Codex IPC owner/follower protocol.
17
+
18
+ ## Library usage
19
+
20
+ ```python
21
+ from pathlib import Path
22
+
23
+ from codex_ipc import CodexIpcConfig, CodexIpcSession
24
+
25
+
26
+ async def main() -> None:
27
+ config = CodexIpcConfig(
28
+ codex_bin=Path("/opt/homebrew/bin/codex"),
29
+ host_id="local",
30
+ model="gpt-5.4",
31
+ reasoning_effort="high",
32
+ )
33
+
34
+ async with CodexIpcSession(config) as session:
35
+ thread = await session.start_new_thread()
36
+ print(f"started {thread.id}")
37
+
38
+ async for state in session.watch_state():
39
+ # Forward this raw conversation state to a browser over WebSocket.
40
+ print(state)
41
+
42
+
43
+ async def resume_existing(thread_id: str) -> None:
44
+ async with CodexIpcSession(CodexIpcConfig(thread_id=thread_id)) as session:
45
+ await session.hydrate_initial_state()
46
+ await session.run_prompt("Summarize the current repo", wait_for_completion=True)
47
+ ```
48
+
49
+ If `codex_bin` is omitted, the vendored app-server SDK uses its normal runtime
50
+ resolver. Pass a `codex_app_server.AppServerConfig` as `app_server_config` when
51
+ you need full control over app-server launch arguments.
52
+
53
+ ## TUI demo
54
+
55
+ The TUI is a development tool and is kept out of the core dependency set:
56
+
57
+ ```bash
58
+ uv run --extra tui codex-ipc-demo --codex-bin /opt/homebrew/bin/codex
59
+ ```
60
+
61
+ Core imports such as `import codex_ipc` do not require `textual`, `rich`, or
62
+ `pillow`.
@@ -0,0 +1,50 @@
1
+ # codex-ipc
2
+
3
+ Python facade for running Codex app-server sessions and sharing their live
4
+ conversation state over the Codex IPC owner/follower protocol.
5
+
6
+ ## Library usage
7
+
8
+ ```python
9
+ from pathlib import Path
10
+
11
+ from codex_ipc import CodexIpcConfig, CodexIpcSession
12
+
13
+
14
+ async def main() -> None:
15
+ config = CodexIpcConfig(
16
+ codex_bin=Path("/opt/homebrew/bin/codex"),
17
+ host_id="local",
18
+ model="gpt-5.4",
19
+ reasoning_effort="high",
20
+ )
21
+
22
+ async with CodexIpcSession(config) as session:
23
+ thread = await session.start_new_thread()
24
+ print(f"started {thread.id}")
25
+
26
+ async for state in session.watch_state():
27
+ # Forward this raw conversation state to a browser over WebSocket.
28
+ print(state)
29
+
30
+
31
+ async def resume_existing(thread_id: str) -> None:
32
+ async with CodexIpcSession(CodexIpcConfig(thread_id=thread_id)) as session:
33
+ await session.hydrate_initial_state()
34
+ await session.run_prompt("Summarize the current repo", wait_for_completion=True)
35
+ ```
36
+
37
+ If `codex_bin` is omitted, the vendored app-server SDK uses its normal runtime
38
+ resolver. Pass a `codex_app_server.AppServerConfig` as `app_server_config` when
39
+ you need full control over app-server launch arguments.
40
+
41
+ ## TUI demo
42
+
43
+ The TUI is a development tool and is kept out of the core dependency set:
44
+
45
+ ```bash
46
+ uv run --extra tui codex-ipc-demo --codex-bin /opt/homebrew/bin/codex
47
+ ```
48
+
49
+ Core imports such as `import codex_ipc` do not require `textual`, `rich`, or
50
+ `pillow`.
@@ -0,0 +1,451 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import curses
7
+ import json
8
+ import sys
9
+ import textwrap
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from codex_ipc.config import CodexIpcConfig
14
+ from codex_ipc.session import CodexIpcSession
15
+ from ipc.conversation_state import conversation_messages
16
+
17
+ DEFAULT_HOST_ID = "local"
18
+
19
+
20
+ class OptionalDependencyError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def safe_addstr(window: Any, y: int, x: int, text: str, attrs: int = 0) -> None:
25
+ try:
26
+ height, width = window.getmaxyx()
27
+ if y < 0 or y >= height or x >= width:
28
+ return
29
+ window.addstr(y, max(0, x), text[: max(0, width - max(0, x) - 1)], attrs)
30
+ except curses.error:
31
+ return
32
+
33
+
34
+ class TerminalChatUI:
35
+ def __init__(self, session: Any) -> None:
36
+ self.session = session
37
+ self.input_buffer = ""
38
+ self.status_lines: list[str] = []
39
+ self._needs_render = True
40
+ self._running = True
41
+
42
+ def request_render(self) -> None:
43
+ self._needs_render = True
44
+
45
+ def set_status(self, message: str) -> None:
46
+ self.status_lines.append(message)
47
+ self.status_lines = self.status_lines[-3:]
48
+ self.request_render()
49
+
50
+ async def run(self) -> None:
51
+ stdscr = curses.initscr()
52
+ try:
53
+ curses.noecho()
54
+ curses.cbreak()
55
+ with contextlib.suppress(curses.error):
56
+ curses.curs_set(1)
57
+ stdscr.keypad(True)
58
+ stdscr.nodelay(True)
59
+
60
+ while self._running:
61
+ if self._needs_render:
62
+ self._render(stdscr)
63
+ self._needs_render = False
64
+
65
+ key = self._read_key(stdscr)
66
+ if key is not None:
67
+ await self._handle_key(key)
68
+ self.request_render()
69
+
70
+ await asyncio.sleep(0.03)
71
+ finally:
72
+ with contextlib.suppress(Exception):
73
+ stdscr.keypad(False)
74
+ curses.nocbreak()
75
+ curses.echo()
76
+ curses.endwin()
77
+
78
+ def _read_key(self, stdscr: Any) -> str | int | None:
79
+ try:
80
+ return stdscr.get_wch()
81
+ except curses.error:
82
+ return None
83
+
84
+ async def _handle_key(self, key: str | int) -> None:
85
+ if isinstance(key, str):
86
+ if key in {"\n", "\r"}:
87
+ await self._submit_input()
88
+ return
89
+ if key in {"\x03", "\x04"}:
90
+ self._running = False
91
+ return
92
+ if key in {"\x7f", "\b"}:
93
+ self.input_buffer = self.input_buffer[:-1]
94
+ return
95
+ if key.isprintable():
96
+ self.input_buffer += key
97
+ return
98
+
99
+ if key == curses.KEY_BACKSPACE:
100
+ self.input_buffer = self.input_buffer[:-1]
101
+ elif key == curses.KEY_RESIZE:
102
+ self.request_render()
103
+
104
+ async def _submit_input(self) -> None:
105
+ command = self.input_buffer.strip()
106
+ self.input_buffer = ""
107
+ if not command:
108
+ return
109
+
110
+ try:
111
+ await self._run_command(command)
112
+ except Exception as exc:
113
+ self.set_status(f"error: {exc}")
114
+
115
+ async def _run_command(self, command: str) -> None:
116
+ if command in {"/exit", "/quit"}:
117
+ self._running = False
118
+ return
119
+ if command == "/role":
120
+ self.set_status(json.dumps(self.session.stream_role, ensure_ascii=False))
121
+ return
122
+ if command == "/state":
123
+ self.set_status(
124
+ "state printed above is disabled in chat UI; use /role or inspect logs"
125
+ )
126
+ return
127
+ if command in ("/start-thread", "/new"):
128
+ thread = await self.session.start_new_thread()
129
+ self.set_status(
130
+ f"started thread {getattr(thread, 'id', self.session.thread_id)}"
131
+ )
132
+ return
133
+ if command == "/resume":
134
+ await self.session.ensure_owner()
135
+ self.set_status("resumed locally and became owner")
136
+ return
137
+ if command == "/archive":
138
+ await self.session.archive_thread()
139
+ self.set_status("archived current thread")
140
+ return
141
+ if command == "/unarchive":
142
+ await self.session.unarchive_thread()
143
+ self.set_status("unarchived current thread")
144
+ return
145
+ if command.startswith("/rename "):
146
+ await self.session.set_thread_name(command[8:].strip())
147
+ self.set_status("renamed current thread")
148
+ return
149
+ if command == "/compact":
150
+ await self.session.compact_thread()
151
+ self.set_status("compact requested")
152
+ return
153
+ if command == "/interrupt":
154
+ await self.session.interrupt_turn()
155
+ self.set_status("interrupt requested")
156
+ return
157
+ if command == "/wait":
158
+ await self.session.wait_until_idle()
159
+ self.set_status("conversation is idle")
160
+ return
161
+ if command.startswith("/steer "):
162
+ await self.session.steer_turn(command[7:].strip())
163
+ return
164
+ if command.startswith("/ask "):
165
+ await self.session.run_prompt(
166
+ command[5:].strip(), wait_for_completion=False
167
+ )
168
+ return
169
+
170
+ await self.session.run_prompt(command, wait_for_completion=False)
171
+
172
+ def _render(self, stdscr: Any) -> None:
173
+ height, width = stdscr.getmaxyx()
174
+ stdscr.erase()
175
+
176
+ role = self.session.stream_role or {"role": "none"}
177
+ role_text = json.dumps(role, ensure_ascii=False)
178
+ title = f"Codex IPC demo | thread={self.session.thread_id} | role={role_text}"
179
+ safe_addstr(stdscr, 0, 0, title, curses.A_BOLD)
180
+
181
+ status = (
182
+ self.status_lines[-1]
183
+ if self.status_lines
184
+ else "Type a message. Commands: /resume /role /compact /interrupt /wait /quit"
185
+ )
186
+ safe_addstr(stdscr, 1, 0, status[: max(1, width - 1)])
187
+
188
+ separator = "-" * max(0, width - 1)
189
+ safe_addstr(stdscr, max(0, height - 3), 0, separator)
190
+
191
+ input_prompt = "> "
192
+ input_line = f"{input_prompt}{self.input_buffer}"
193
+ safe_addstr(stdscr, max(0, height - 2), 0, input_line)
194
+ safe_addstr(
195
+ stdscr, max(0, height - 1), 0, "Enter sends. Plain text is a user message."
196
+ )
197
+
198
+ chat_top = 3
199
+ chat_bottom = max(chat_top, height - 4)
200
+ chat_height = max(0, chat_bottom - chat_top + 1)
201
+ rendered = self._rendered_chat_lines(width)
202
+ visible = rendered[-chat_height:]
203
+ for offset, (x, text, attrs) in enumerate(visible):
204
+ safe_addstr(stdscr, chat_top + offset, x, text, attrs)
205
+
206
+ cursor_x = min(width - 1, len(input_line))
207
+ with contextlib.suppress(curses.error):
208
+ stdscr.move(max(0, height - 2), cursor_x)
209
+ stdscr.refresh()
210
+
211
+ def _rendered_chat_lines(self, width: int) -> list[tuple[int, str, int]]:
212
+ lines: list[tuple[int, str, int]] = []
213
+ max_bubble_width = max(20, min(80, width - 8, int(width * 0.72)))
214
+
215
+ label_map = {
216
+ "user": "You",
217
+ "assistant": "AI",
218
+ "steer": "Steer",
219
+ "hookPrompt": "Hook Prompt",
220
+ "plan": "Plan",
221
+ "commandExecution": "Shell",
222
+ "dynamicToolCall": "Tool",
223
+ "fileChange": "Files",
224
+ "mcpToolCall": "MCP",
225
+ "collabAgentToolCall": "Agent",
226
+ "hook": "Hook",
227
+ "reasoning": "Reasoning",
228
+ "webSearch": "Search",
229
+ "imageView": "Image View",
230
+ "enteredReviewMode": "Review On",
231
+ "exitedReviewMode": "Review Off",
232
+ "contextCompaction": "Compaction",
233
+ }
234
+
235
+ for role, text in conversation_messages(self.session.state):
236
+ if lines:
237
+ lines.append((0, "", 0))
238
+
239
+ label = label_map.get(role, role)
240
+ attrs = curses.A_BOLD if role in {"user", "steer"} else 0
241
+ wrapped = self._wrap_text(text, max_bubble_width)
242
+ if role in {"user", "steer"}:
243
+ content_width = max(
244
+ [len(label), *(len(line) for line in wrapped)], default=0
245
+ )
246
+ x = max(0, width - content_width - 2)
247
+ lines.append((x, label, attrs))
248
+ for line in wrapped:
249
+ lines.append((max(0, width - len(line) - 2), line, attrs))
250
+ else:
251
+ lines.append((0, label, attrs))
252
+ for line in wrapped:
253
+ lines.append((0, line, attrs))
254
+
255
+ if not lines:
256
+ lines.append(
257
+ (0, "No messages yet. Waiting for snapshot or local resume...", 0)
258
+ )
259
+ return lines
260
+
261
+ def _wrap_text(self, text: str, width: int) -> list[str]:
262
+ if not text:
263
+ return [""]
264
+
265
+ wrapped: list[str] = []
266
+ for paragraph in text.splitlines() or [""]:
267
+ if not paragraph:
268
+ wrapped.append("")
269
+ continue
270
+ wrapped.extend(
271
+ textwrap.wrap(
272
+ paragraph,
273
+ width=width,
274
+ replace_whitespace=False,
275
+ drop_whitespace=False,
276
+ )
277
+ or [""]
278
+ )
279
+ return wrapped
280
+
281
+
282
+ class DemoApp:
283
+ def __init__(
284
+ self,
285
+ *,
286
+ host_id: str,
287
+ thread_id: str | None,
288
+ model: str,
289
+ reasoning_effort: str,
290
+ codex_bin: Path | None = None,
291
+ ) -> None:
292
+ self._ui: TerminalChatUI | None = None
293
+ self.session = CodexIpcSession(
294
+ CodexIpcConfig(
295
+ thread_id=thread_id,
296
+ host_id=host_id,
297
+ client_type="demo",
298
+ model=model,
299
+ reasoning_effort=reasoning_effort,
300
+ codex_bin=codex_bin,
301
+ ),
302
+ notify=self._notify,
303
+ state_listener=lambda _state: self._request_render(),
304
+ )
305
+
306
+ @property
307
+ def thread_id(self) -> str:
308
+ return self.session.thread_id
309
+
310
+ async def start(self) -> None:
311
+ await self.session.start()
312
+
313
+ async def stop(self) -> None:
314
+ await self.session.stop()
315
+
316
+ async def repl(self) -> None:
317
+ await self.session.hydrate_initial_state()
318
+ try:
319
+ from examples.tui import CodexWorkbenchApp
320
+ except ModuleNotFoundError as exc:
321
+ if exc.name in {"PIL", "rich", "textual"}:
322
+ raise OptionalDependencyError(
323
+ "The demo TUI requires optional dependencies. "
324
+ "Install with `codex-ipc[tui]`."
325
+ ) from exc
326
+ raise
327
+
328
+ ui = CodexWorkbenchApp(self)
329
+ self._ui = ui
330
+ try:
331
+ await ui.run_async()
332
+ finally:
333
+ self._ui = None
334
+
335
+ async def run_prompt(
336
+ self,
337
+ prompt: str,
338
+ *,
339
+ wait_for_completion: bool,
340
+ collaboration_mode: dict[str, Any] | None = None,
341
+ ) -> None:
342
+ await self.session.run_prompt(
343
+ prompt,
344
+ wait_for_completion=wait_for_completion,
345
+ collaboration_mode=collaboration_mode,
346
+ )
347
+
348
+ def plan_collaboration_mode(self) -> dict[str, Any]:
349
+ return self.session.plan_collaboration_mode()
350
+
351
+ async def set_collaboration_mode(
352
+ self,
353
+ collaboration_mode: dict[str, Any] | None,
354
+ ) -> None:
355
+ await self.session.set_collaboration_mode(collaboration_mode)
356
+
357
+ async def submit_user_input_response(
358
+ self,
359
+ request_id: str,
360
+ response: dict[str, Any],
361
+ ) -> bool:
362
+ return await self.session.submit_user_input_response(request_id, response)
363
+
364
+ async def steer_prompt(self, prompt: str) -> None:
365
+ await self.session.steer_prompt(prompt)
366
+
367
+ async def enqueue_followup(self, prompt: str) -> dict[str, Any]:
368
+ return await self.session.enqueue_followup(prompt)
369
+
370
+ async def remove_followup(self, message_id: str) -> None:
371
+ await self.session.remove_followup(message_id)
372
+
373
+ def is_turn_in_progress(self) -> bool:
374
+ return self.session.is_turn_in_progress()
375
+
376
+ def queued_followups(self) -> list[dict[str, Any]]:
377
+ return self.session.queued_followups
378
+
379
+ async def list_threads(self, params: dict[str, Any] | None = None) -> object:
380
+ return await self.session.list_threads(params)
381
+
382
+ async def activate_thread(self, thread_id: str) -> None:
383
+ await self.session.activate_thread(thread_id)
384
+ await self.session.hydrate_initial_state()
385
+
386
+ async def start_new_thread(self, params: dict[str, Any] | None = None) -> object:
387
+ return await self.session.start_new_thread(params)
388
+
389
+ def _notify(self, message: str) -> None:
390
+ if self._ui is not None:
391
+ self._ui.set_status(message)
392
+ else:
393
+ print(f"[ipc] {message}")
394
+
395
+ def _request_render(self) -> None:
396
+ if self._ui is not None:
397
+ self._ui.request_render()
398
+
399
+
400
+ def parse_args() -> argparse.Namespace:
401
+ parser = argparse.ArgumentParser(description="Codex IPC owner/follower demo")
402
+ parser.add_argument(
403
+ "--host-id",
404
+ default=DEFAULT_HOST_ID,
405
+ help=(
406
+ "Stable logical host ID shared by windows connected to the same "
407
+ "backend host. Use different values to simulate separate hosts."
408
+ ),
409
+ )
410
+ parser.add_argument("--thread-id")
411
+ parser.add_argument("--codex-bin", type=Path)
412
+ parser.add_argument("--model", default="gpt-5.4")
413
+ parser.add_argument("--reasoning-effort", default="high")
414
+ parser.add_argument("--prompt")
415
+ parser.add_argument("--wait", action="store_true")
416
+ parser.add_argument("--no-repl", action="store_true")
417
+ return parser.parse_args()
418
+
419
+
420
+ async def main() -> None:
421
+ args = parse_args()
422
+ app = DemoApp(
423
+ host_id=args.host_id,
424
+ thread_id=args.thread_id,
425
+ model=args.model,
426
+ reasoning_effort=args.reasoning_effort,
427
+ codex_bin=Path("/opt/homebrew/bin/codex"),
428
+ )
429
+
430
+ try:
431
+ await app.start()
432
+ if args.prompt:
433
+ if not app.thread_id:
434
+ await app.start_new_thread()
435
+ await app.run_prompt(args.prompt, wait_for_completion=args.wait)
436
+ if not args.no_repl:
437
+ await app.repl()
438
+ finally:
439
+ await app.stop()
440
+
441
+
442
+ def main_cli() -> None:
443
+ try:
444
+ asyncio.run(main())
445
+ except OptionalDependencyError as exc:
446
+ print(f"error: {exc}", file=sys.stderr)
447
+ raise SystemExit(1) from None
448
+
449
+
450
+ if __name__ == "__main__":
451
+ main_cli()