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.
- codex_ipc-0.1.0/.github/workflows/publish.yml +90 -0
- codex_ipc-0.1.0/.gitignore +13 -0
- codex_ipc-0.1.0/.python-version +1 -0
- codex_ipc-0.1.0/.vscode/launch.json +19 -0
- codex_ipc-0.1.0/PKG-INFO +62 -0
- codex_ipc-0.1.0/README.md +50 -0
- codex_ipc-0.1.0/examples/demo.py +451 -0
- codex_ipc-0.1.0/examples/tui.py +1055 -0
- codex_ipc-0.1.0/packages/codex-sdk/README.md +24 -0
- codex_ipc-0.1.0/packages/codex-sdk/pyproject.toml +34 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/__init__.py +112 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_inputs.py +63 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_message_router.py +158 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_run.py +112 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/_version.py +37 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/api.py +764 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/async_client.py +214 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/client.py +582 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/errors.py +125 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/__init__.py +1 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/notification_registry.py +172 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/generated/v2_all.py +8715 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/models.py +99 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/py.typed +0 -0
- codex_ipc-0.1.0/packages/codex-sdk/src/codex_app_server/retry.py +41 -0
- codex_ipc-0.1.0/packages/ipc/README.md +17 -0
- codex_ipc-0.1.0/packages/ipc/pyproject.toml +34 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/__init__.py +22 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/client.py +459 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/constants.py +55 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/conversation_state.py +846 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/frame_protocol.py +84 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/immer.py +207 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/owner_follower.py +2651 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/py.typed +0 -0
- codex_ipc-0.1.0/packages/ipc/src/ipc/router.py +555 -0
- codex_ipc-0.1.0/pyproject.toml +29 -0
- codex_ipc-0.1.0/src/codex_ipc/__init__.py +28 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/__init__.py +0 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/api.py +285 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/async_client.py +77 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/client.py +245 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/generated/__init__.py +0 -0
- codex_ipc-0.1.0/src/codex_ipc/codex/generated/v2_all.py +83 -0
- codex_ipc-0.1.0/src/codex_ipc/config.py +28 -0
- codex_ipc-0.1.0/src/codex_ipc/session.py +509 -0
- codex_ipc-0.1.0/src/codex_ipc/user_input.py +174 -0
- codex_ipc-0.1.0/tests/__init__.py +1 -0
- codex_ipc-0.1.0/tests/test_plan_mode_user_input.py +706 -0
- 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 @@
|
|
|
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
|
+
}
|
codex_ipc-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|