open-codex-ui 0.1.4__py3-none-any.whl
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.
- open_codex_ui-0.1.4.dist-info/METADATA +249 -0
- open_codex_ui-0.1.4.dist-info/RECORD +40 -0
- open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
- open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
- open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
- yier_web/__init__.py +6 -0
- yier_web/app.py +223 -0
- yier_web/auth.py +269 -0
- yier_web/cli.py +172 -0
- yier_web/codex/__init__.py +3 -0
- yier_web/codex/ipc_manager.py +1761 -0
- yier_web/codex/session_events.py +110 -0
- yier_web/codex/ws_commands.py +299 -0
- yier_web/config.py +651 -0
- yier_web/directory_picker.py +93 -0
- yier_web/event_stream.py +29 -0
- yier_web/frontend.py +204 -0
- yier_web/routes/__init__.py +17 -0
- yier_web/routes/codex.py +573 -0
- yier_web/routes/core.py +281 -0
- yier_web/schemas.py +534 -0
- yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
- yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
- yier_web/static/assets/LoginView-CELCom2O.js +101 -0
- yier_web/static/assets/api-CeihACIV.js +1099 -0
- yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
- yier_web/static/assets/index-CjVNk6ja.js +183 -0
- yier_web/static/assets/index-mSBvq1p8.js +79 -0
- yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
- yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
- yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
- yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
- yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
- yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
- yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
- yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
- yier_web/static/brand/open-codex-ui-logo.svg +85 -0
- yier_web/static/favicon.ico +0 -0
- yier_web/static/favicon.svg +99 -0
- yier_web/static/index.html +24 -0
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shlex
|
|
11
|
+
import tomllib
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from codex_bridge import (
|
|
15
|
+
AppServerConfig,
|
|
16
|
+
AsyncCodexClient,
|
|
17
|
+
CodexIpcConfig,
|
|
18
|
+
CodexIpcSession,
|
|
19
|
+
JsonDict,
|
|
20
|
+
SshConnectionConfig,
|
|
21
|
+
SshWebsocketAppServerConfig,
|
|
22
|
+
materialize_conversation_state,
|
|
23
|
+
)
|
|
24
|
+
from openai_codex.generated.v2_all import (
|
|
25
|
+
AbsolutePathBuf,
|
|
26
|
+
ActiveThreadStatus,
|
|
27
|
+
CustomSessionSource,
|
|
28
|
+
IdleThreadStatus,
|
|
29
|
+
NotLoadedThreadStatus,
|
|
30
|
+
SessionSource,
|
|
31
|
+
SessionSourceValue,
|
|
32
|
+
SkillsListResponse,
|
|
33
|
+
SubAgentSessionSource,
|
|
34
|
+
SystemErrorThreadStatus,
|
|
35
|
+
Thread,
|
|
36
|
+
ThreadListResponse,
|
|
37
|
+
ThreadStatus,
|
|
38
|
+
)
|
|
39
|
+
from yier_web.config import AppConfigService
|
|
40
|
+
from yier_web.codex.session_events import (
|
|
41
|
+
CodexSessionEvent,
|
|
42
|
+
CodexSessionEventHub,
|
|
43
|
+
CodexSessionEventQueue,
|
|
44
|
+
CodexSessionEventSink,
|
|
45
|
+
Unsubscribe,
|
|
46
|
+
)
|
|
47
|
+
from yier_web.event_stream import EventStreamBroker
|
|
48
|
+
from yier_web.schemas import (
|
|
49
|
+
CodexFilesystemEntry,
|
|
50
|
+
CodexFilesystemResponse,
|
|
51
|
+
CodexNativeSessionSummary,
|
|
52
|
+
CodexProjectGroup,
|
|
53
|
+
CodexRemoteConnection,
|
|
54
|
+
CodexRemoteConnectionStatus,
|
|
55
|
+
CodexRemoteConnectionTestResponse,
|
|
56
|
+
CodexRemoteConnectionsResponse,
|
|
57
|
+
CodexWorkspaceResponse,
|
|
58
|
+
StoredCodexSettings,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
CODEX_POSIX_INSTALL_URL = "https://chatgpt.com/codex/install.sh"
|
|
63
|
+
CODEX_CONFIG_FILE = "config.toml"
|
|
64
|
+
|
|
65
|
+
CodexSubscriberQueue = CodexSessionEventQueue
|
|
66
|
+
CodexSessionFactory = Callable[..., CodexIpcSession]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class ManagedCodexThread:
|
|
71
|
+
session: CodexIpcSession
|
|
72
|
+
watcher_task: asyncio.Task[None]
|
|
73
|
+
event_watcher_task: asyncio.Task[None] | None = None
|
|
74
|
+
state: JsonDict | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _compact_text(value: object, *, limit: int = 72) -> str:
|
|
78
|
+
text = value.strip() if isinstance(value, str) else ""
|
|
79
|
+
if not text:
|
|
80
|
+
return ""
|
|
81
|
+
compacted = " ".join(text.split())
|
|
82
|
+
if len(compacted) <= limit:
|
|
83
|
+
return compacted
|
|
84
|
+
return f"{compacted[: limit - 3]}..."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _thread_status(status: ThreadStatus) -> str:
|
|
88
|
+
match status.root:
|
|
89
|
+
case NotLoadedThreadStatus(type=status_type):
|
|
90
|
+
return status_type
|
|
91
|
+
case IdleThreadStatus(type=status_type):
|
|
92
|
+
return status_type
|
|
93
|
+
case SystemErrorThreadStatus(type=status_type):
|
|
94
|
+
return status_type
|
|
95
|
+
case ActiveThreadStatus(type=status_type):
|
|
96
|
+
return status_type
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _thread_source(source: SessionSource) -> str:
|
|
100
|
+
match source.root:
|
|
101
|
+
case SessionSourceValue() as source_value:
|
|
102
|
+
return source_value.value
|
|
103
|
+
case CustomSessionSource(custom=custom):
|
|
104
|
+
return custom
|
|
105
|
+
case SubAgentSessionSource():
|
|
106
|
+
return "subAgent"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _project_from_cwd(cwd: AbsolutePathBuf) -> tuple[str, str]:
|
|
110
|
+
path = Path(cwd.root).expanduser()
|
|
111
|
+
project = path.name or cwd.root
|
|
112
|
+
return (project, str(path))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _summary_used_at(summary: CodexNativeSessionSummary) -> float:
|
|
116
|
+
return summary.updated_at or summary.started_at
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _codex_home(home_dir: Path | None = None) -> Path:
|
|
120
|
+
configured = os.environ.get("CODEX_HOME")
|
|
121
|
+
if configured:
|
|
122
|
+
return Path(configured).expanduser()
|
|
123
|
+
return (home_dir or Path.home()).expanduser() / ".codex"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _load_codex_home_config(codex_home: Path) -> JsonDict:
|
|
127
|
+
config_path = codex_home / CODEX_CONFIG_FILE
|
|
128
|
+
try:
|
|
129
|
+
with config_path.open("rb") as handle:
|
|
130
|
+
payload = tomllib.load(handle)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return {}
|
|
133
|
+
except tomllib.TOMLDecodeError as exc:
|
|
134
|
+
logger.warning("Unable to parse %s: %s", config_path, exc)
|
|
135
|
+
return {}
|
|
136
|
+
return payload if isinstance(payload, dict) else {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _config_string(config: JsonDict, key: str) -> str | None:
|
|
140
|
+
value = config.get(key)
|
|
141
|
+
if isinstance(value, str) and value.strip():
|
|
142
|
+
return value.strip()
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _thread_summary(
|
|
147
|
+
thread: Thread, *, host_id: str = "local"
|
|
148
|
+
) -> CodexNativeSessionSummary:
|
|
149
|
+
cwd = thread.cwd.root
|
|
150
|
+
project, project_path = _project_from_cwd(thread.cwd)
|
|
151
|
+
if host_id == "local":
|
|
152
|
+
project_path = str(Path(project_path).resolve())
|
|
153
|
+
name = _compact_text(thread.name)
|
|
154
|
+
preview = _compact_text(thread.preview, limit=120)
|
|
155
|
+
title = name or preview or thread.id
|
|
156
|
+
return CodexNativeSessionSummary(
|
|
157
|
+
thread_id=thread.id,
|
|
158
|
+
host_id=host_id,
|
|
159
|
+
title=title,
|
|
160
|
+
preview=preview or title,
|
|
161
|
+
updated_at=float(thread.updated_at),
|
|
162
|
+
started_at=float(thread.created_at),
|
|
163
|
+
status=_thread_status(thread.status),
|
|
164
|
+
cwd=cwd,
|
|
165
|
+
project=project,
|
|
166
|
+
project_path=project_path,
|
|
167
|
+
source=_thread_source(thread.source),
|
|
168
|
+
model_provider=thread.model_provider,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CodexIpcManager:
|
|
173
|
+
"""Owns long-lived Codex IPC sessions for the web workspace.
|
|
174
|
+
|
|
175
|
+
The manager keeps one ``CodexIpcSession`` per active thread so UI subscription
|
|
176
|
+
changes do not interrupt ongoing turns.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
config_service: AppConfigService,
|
|
183
|
+
event_broker: EventStreamBroker,
|
|
184
|
+
session_factory: CodexSessionFactory = CodexIpcSession,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.config_service = config_service
|
|
187
|
+
self.event_broker = event_broker
|
|
188
|
+
self._session_factory = session_factory
|
|
189
|
+
self._threads: dict[str, ManagedCodexThread] = {}
|
|
190
|
+
self._thread_hosts: dict[str, str] = {}
|
|
191
|
+
self._session_events = CodexSessionEventHub()
|
|
192
|
+
self._session_events.add_sink(self._publish_session_event_to_broker)
|
|
193
|
+
self._workspace_sessions: dict[str, CodexIpcSession] = {}
|
|
194
|
+
self._remote_connection_statuses: dict[str, CodexRemoteConnectionStatus] = {}
|
|
195
|
+
self._lock = asyncio.Lock()
|
|
196
|
+
self._started = False
|
|
197
|
+
|
|
198
|
+
async def start(self) -> None:
|
|
199
|
+
self._started = True
|
|
200
|
+
|
|
201
|
+
async def stop(self) -> None:
|
|
202
|
+
self._started = False
|
|
203
|
+
for managed in list(self._threads.values()):
|
|
204
|
+
self._cancel_managed_watchers(managed)
|
|
205
|
+
for managed in list(self._threads.values()):
|
|
206
|
+
await self._wait_managed_watchers(managed)
|
|
207
|
+
await managed.session.stop()
|
|
208
|
+
self._threads.clear()
|
|
209
|
+
self._session_events.clear_thread_subscribers()
|
|
210
|
+
|
|
211
|
+
for session in list(self._workspace_sessions.values()):
|
|
212
|
+
await session.stop()
|
|
213
|
+
self._workspace_sessions.clear()
|
|
214
|
+
|
|
215
|
+
async def workspace(self) -> CodexWorkspaceResponse:
|
|
216
|
+
settings = self.config_service.load_web_settings().codex
|
|
217
|
+
host_ids = [
|
|
218
|
+
"local",
|
|
219
|
+
*(
|
|
220
|
+
self._host_id_for_connection(connection.id)
|
|
221
|
+
for connection in settings.remote_connections
|
|
222
|
+
),
|
|
223
|
+
]
|
|
224
|
+
results = await asyncio.gather(
|
|
225
|
+
*(self._list_workspace_host(host_id) for host_id in host_ids),
|
|
226
|
+
return_exceptions=True,
|
|
227
|
+
)
|
|
228
|
+
summaries: list[CodexNativeSessionSummary] = []
|
|
229
|
+
for host_id, result in zip(host_ids, results, strict=True):
|
|
230
|
+
connection_id = self._connection_id_from_host(host_id)
|
|
231
|
+
if isinstance(result, BaseException):
|
|
232
|
+
if connection_id:
|
|
233
|
+
self._set_remote_connection_status(
|
|
234
|
+
connection_id,
|
|
235
|
+
"error",
|
|
236
|
+
_compact_text(str(result), limit=180)
|
|
237
|
+
or result.__class__.__name__,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
logger.warning("Unable to list local Codex threads: %s", result)
|
|
241
|
+
continue
|
|
242
|
+
if connection_id:
|
|
243
|
+
self._set_remote_connection_status(
|
|
244
|
+
connection_id,
|
|
245
|
+
"connected",
|
|
246
|
+
"Connected",
|
|
247
|
+
)
|
|
248
|
+
summaries.extend(self._summaries_from_threads(result, host_id=host_id))
|
|
249
|
+
|
|
250
|
+
workspace = self._workspace_from_summaries(summaries, settings=settings)
|
|
251
|
+
remote = self.remote_connections()
|
|
252
|
+
workspace.remote_connections = remote.connections
|
|
253
|
+
workspace.active_remote_connection_id = remote.active_connection_id
|
|
254
|
+
workspace.remote_connection_statuses = remote.statuses
|
|
255
|
+
return workspace
|
|
256
|
+
|
|
257
|
+
async def _list_workspace_host(self, host_id: str) -> ThreadListResponse:
|
|
258
|
+
try:
|
|
259
|
+
return await asyncio.wait_for(self.list_threads(host_id), timeout=5)
|
|
260
|
+
except Exception:
|
|
261
|
+
await self._close_workspace_session(host_id)
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
def remote_connections(self) -> CodexRemoteConnectionsResponse:
|
|
265
|
+
settings = self.config_service.load_web_settings().codex
|
|
266
|
+
return CodexRemoteConnectionsResponse(
|
|
267
|
+
connections=settings.remote_connections,
|
|
268
|
+
active_connection_id=settings.active_remote_connection_id,
|
|
269
|
+
statuses=self._remote_statuses_for(settings),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def activate_remote_connection(self, connection_id: str) -> None:
|
|
273
|
+
self.config_service.set_active_codex_remote_connection(connection_id)
|
|
274
|
+
|
|
275
|
+
async def restart_remote_connection(self, connection_id: str) -> None:
|
|
276
|
+
if self._remote_connection_by_id(connection_id) is None:
|
|
277
|
+
raise ValueError("Remote connection not found.")
|
|
278
|
+
self._set_remote_connection_status(
|
|
279
|
+
connection_id,
|
|
280
|
+
"connecting",
|
|
281
|
+
"Restarting connection",
|
|
282
|
+
)
|
|
283
|
+
await self._restart_host_sessions(self._host_id_for_connection(connection_id))
|
|
284
|
+
|
|
285
|
+
async def disconnect_remote_connection(self, connection_id: str) -> None:
|
|
286
|
+
await self._restart_host_sessions(self._host_id_for_connection(connection_id))
|
|
287
|
+
self._remote_connection_statuses.pop(connection_id, None)
|
|
288
|
+
|
|
289
|
+
async def install_remote_codex(
|
|
290
|
+
self,
|
|
291
|
+
connection_id: str,
|
|
292
|
+
) -> CodexRemoteConnectionTestResponse:
|
|
293
|
+
connection = self._remote_connection_by_id(connection_id)
|
|
294
|
+
if connection is None:
|
|
295
|
+
raise ValueError("Remote connection not found.")
|
|
296
|
+
self._set_remote_connection_status(
|
|
297
|
+
connection_id, "connecting", "Installing Codex"
|
|
298
|
+
)
|
|
299
|
+
install_script = (
|
|
300
|
+
"if command -v curl >/dev/null 2>&1; then "
|
|
301
|
+
f'installer_script="$(curl -fsSL {CODEX_POSIX_INSTALL_URL})" || exit; '
|
|
302
|
+
"elif command -v wget >/dev/null 2>&1; then "
|
|
303
|
+
f'installer_script="$(wget -qO- {CODEX_POSIX_INSTALL_URL})" || exit; '
|
|
304
|
+
"else echo 'curl or wget is required to install Codex' >&2; exit 127; fi; "
|
|
305
|
+
"printf '%s\\n' \"$installer_script\" | "
|
|
306
|
+
"CODEX_RELEASE=latest CODEX_NON_INTERACTIVE=1 sh"
|
|
307
|
+
)
|
|
308
|
+
process = await asyncio.create_subprocess_exec(
|
|
309
|
+
*self._ssh_base_args(connection),
|
|
310
|
+
self._remote_login_shell_command(install_script),
|
|
311
|
+
stdout=asyncio.subprocess.PIPE,
|
|
312
|
+
stderr=asyncio.subprocess.PIPE,
|
|
313
|
+
)
|
|
314
|
+
try:
|
|
315
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=600)
|
|
316
|
+
except asyncio.TimeoutError:
|
|
317
|
+
process.kill()
|
|
318
|
+
await process.wait()
|
|
319
|
+
detail = "Remote Codex install timed out."
|
|
320
|
+
self._set_remote_connection_status(connection_id, "error", detail)
|
|
321
|
+
return CodexRemoteConnectionTestResponse(ok=False, detail=detail)
|
|
322
|
+
detail = "\n".join(
|
|
323
|
+
item.decode("utf-8", errors="replace").strip()
|
|
324
|
+
for item in (stdout, stderr)
|
|
325
|
+
if item
|
|
326
|
+
).strip()
|
|
327
|
+
ok = process.returncode == 0
|
|
328
|
+
if ok:
|
|
329
|
+
await self.restart_remote_connection(connection_id)
|
|
330
|
+
else:
|
|
331
|
+
self._set_remote_connection_status(
|
|
332
|
+
connection_id,
|
|
333
|
+
"error",
|
|
334
|
+
detail or f"Install exited with code {process.returncode}.",
|
|
335
|
+
)
|
|
336
|
+
return CodexRemoteConnectionTestResponse(
|
|
337
|
+
ok=ok,
|
|
338
|
+
detail=detail or ("Codex installed." if ok else "Codex install failed."),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
async def login_remote_api_key(
|
|
342
|
+
self,
|
|
343
|
+
connection_id: str,
|
|
344
|
+
api_key: str,
|
|
345
|
+
) -> CodexRemoteConnectionTestResponse:
|
|
346
|
+
connection = self._remote_connection_by_id(connection_id)
|
|
347
|
+
if connection is None:
|
|
348
|
+
raise ValueError("Remote connection not found.")
|
|
349
|
+
self._set_remote_connection_status(
|
|
350
|
+
connection_id,
|
|
351
|
+
"connecting",
|
|
352
|
+
"Signing in with API key",
|
|
353
|
+
)
|
|
354
|
+
try:
|
|
355
|
+
async with AsyncCodexClient(
|
|
356
|
+
config=self._remote_app_server_config(connection)
|
|
357
|
+
) as client:
|
|
358
|
+
await client.initialize()
|
|
359
|
+
await client.account_login_start(
|
|
360
|
+
{
|
|
361
|
+
"type": "apiKey",
|
|
362
|
+
"apiKey": api_key,
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
account = await client.account_read({"refreshToken": False})
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
detail = _compact_text(exc, limit=180) or exc.__class__.__name__
|
|
368
|
+
self._set_remote_connection_status(connection_id, "error", detail)
|
|
369
|
+
return CodexRemoteConnectionTestResponse(ok=False, detail=detail)
|
|
370
|
+
account_type = (
|
|
371
|
+
account.account.root.type if account.account is not None else "unknown"
|
|
372
|
+
)
|
|
373
|
+
detail = f"Signed in with {account_type}."
|
|
374
|
+
self._set_remote_connection_status(connection_id, "connected", detail)
|
|
375
|
+
return CodexRemoteConnectionTestResponse(ok=True, detail=detail)
|
|
376
|
+
|
|
377
|
+
async def test_remote_connection(
|
|
378
|
+
self,
|
|
379
|
+
connection_id: str,
|
|
380
|
+
) -> CodexRemoteConnectionTestResponse:
|
|
381
|
+
connection = self._remote_connection_by_id(connection_id)
|
|
382
|
+
if connection is None:
|
|
383
|
+
raise ValueError("Remote connection not found.")
|
|
384
|
+
self._set_remote_connection_status(connection_id, "connecting", "Checking")
|
|
385
|
+
args = self._ssh_base_args(connection)
|
|
386
|
+
script = "command -v codex >/dev/null 2>&1 && codex --version"
|
|
387
|
+
process = await asyncio.create_subprocess_exec(
|
|
388
|
+
*args,
|
|
389
|
+
self._remote_login_shell_command(script),
|
|
390
|
+
stdout=asyncio.subprocess.PIPE,
|
|
391
|
+
stderr=asyncio.subprocess.PIPE,
|
|
392
|
+
)
|
|
393
|
+
try:
|
|
394
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=12)
|
|
395
|
+
except asyncio.TimeoutError:
|
|
396
|
+
process.kill()
|
|
397
|
+
await process.wait()
|
|
398
|
+
self._set_remote_connection_status(
|
|
399
|
+
connection_id,
|
|
400
|
+
"error",
|
|
401
|
+
"SSH connection timed out.",
|
|
402
|
+
)
|
|
403
|
+
return CodexRemoteConnectionTestResponse(
|
|
404
|
+
ok=False,
|
|
405
|
+
detail="SSH connection timed out.",
|
|
406
|
+
)
|
|
407
|
+
output = "\n".join(
|
|
408
|
+
item.decode("utf-8", errors="replace").strip()
|
|
409
|
+
for item in (stdout, stderr)
|
|
410
|
+
if item
|
|
411
|
+
).strip()
|
|
412
|
+
ok = process.returncode == 0
|
|
413
|
+
detail = output or (
|
|
414
|
+
"Codex is available on the remote host."
|
|
415
|
+
if ok
|
|
416
|
+
else f"SSH exited with code {process.returncode}."
|
|
417
|
+
)
|
|
418
|
+
self._set_remote_connection_status(
|
|
419
|
+
connection_id,
|
|
420
|
+
"connected" if ok else "error",
|
|
421
|
+
detail,
|
|
422
|
+
)
|
|
423
|
+
return CodexRemoteConnectionTestResponse(ok=ok, detail=detail)
|
|
424
|
+
|
|
425
|
+
async def list_threads(self, host_id: str = "local") -> ThreadListResponse:
|
|
426
|
+
session = await self._ensure_workspace_session(host_id)
|
|
427
|
+
threads: list[Thread] = []
|
|
428
|
+
cursor: str | None = None
|
|
429
|
+
seen_cursors: set[str] = set()
|
|
430
|
+
while True:
|
|
431
|
+
params: JsonDict = {
|
|
432
|
+
"archived": False,
|
|
433
|
+
"limit": 100,
|
|
434
|
+
"sort_key": "updated_at",
|
|
435
|
+
"sort_direction": "desc",
|
|
436
|
+
}
|
|
437
|
+
if cursor:
|
|
438
|
+
params["cursor"] = cursor
|
|
439
|
+
response = await session.list_threads(params)
|
|
440
|
+
threads.extend(response.data)
|
|
441
|
+
next_cursor = response.next_cursor
|
|
442
|
+
if not next_cursor or next_cursor in seen_cursors:
|
|
443
|
+
break
|
|
444
|
+
seen_cursors.add(next_cursor)
|
|
445
|
+
cursor = next_cursor
|
|
446
|
+
return ThreadListResponse(data=threads)
|
|
447
|
+
|
|
448
|
+
async def list_filesystem(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
host_id: str,
|
|
452
|
+
path: str | None = None,
|
|
453
|
+
) -> CodexFilesystemResponse:
|
|
454
|
+
resolved_host_id = self._resolve_host_id(host_id)
|
|
455
|
+
connection = self._connection_for_host(resolved_host_id)
|
|
456
|
+
if connection is None:
|
|
457
|
+
raise ValueError("A remote host is required.")
|
|
458
|
+
|
|
459
|
+
requested_path = path or connection.remote_path or "~"
|
|
460
|
+
script = """
|
|
461
|
+
import json
|
|
462
|
+
import os
|
|
463
|
+
import pathlib
|
|
464
|
+
import sys
|
|
465
|
+
|
|
466
|
+
directory = pathlib.Path(sys.argv[1]).expanduser().resolve()
|
|
467
|
+
if not directory.exists():
|
|
468
|
+
raise FileNotFoundError(f"Path not found: {directory}")
|
|
469
|
+
if not directory.is_dir():
|
|
470
|
+
raise NotADirectoryError(f"Path is not a directory: {directory}")
|
|
471
|
+
|
|
472
|
+
entries = []
|
|
473
|
+
for child in directory.iterdir():
|
|
474
|
+
try:
|
|
475
|
+
kind = "directory" if child.is_dir() else "file" if child.is_file() else "other"
|
|
476
|
+
readable = os.access(child, os.R_OK)
|
|
477
|
+
except OSError:
|
|
478
|
+
kind = "other"
|
|
479
|
+
readable = False
|
|
480
|
+
entries.append({
|
|
481
|
+
"name": child.name,
|
|
482
|
+
"path": str(child),
|
|
483
|
+
"kind": kind,
|
|
484
|
+
"extension": child.suffix.lower() if kind == "file" else "",
|
|
485
|
+
"readable": readable,
|
|
486
|
+
})
|
|
487
|
+
entries.sort(key=lambda item: (
|
|
488
|
+
0 if item["kind"] == "directory" else 1 if item["kind"] == "file" else 2,
|
|
489
|
+
item["name"].casefold(),
|
|
490
|
+
))
|
|
491
|
+
print(json.dumps({
|
|
492
|
+
"path": str(directory),
|
|
493
|
+
"parent_path": None if directory.parent == directory else str(directory.parent),
|
|
494
|
+
"entries": entries,
|
|
495
|
+
}))
|
|
496
|
+
""".strip()
|
|
497
|
+
command = f"python3 -c {shlex.quote(script)} {shlex.quote(requested_path)}"
|
|
498
|
+
process = await asyncio.create_subprocess_exec(
|
|
499
|
+
*self._ssh_base_args(connection, verbose=False),
|
|
500
|
+
command,
|
|
501
|
+
stdout=asyncio.subprocess.PIPE,
|
|
502
|
+
stderr=asyncio.subprocess.PIPE,
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=12)
|
|
506
|
+
except TimeoutError:
|
|
507
|
+
process.kill()
|
|
508
|
+
await process.wait()
|
|
509
|
+
raise RuntimeError("Timed out while browsing the remote host.") from None
|
|
510
|
+
if process.returncode != 0:
|
|
511
|
+
detail = _compact_text(stderr.decode(errors="replace"), limit=240)
|
|
512
|
+
raise RuntimeError(detail or "Unable to browse the remote host.")
|
|
513
|
+
try:
|
|
514
|
+
payload = json.loads(stdout.decode())
|
|
515
|
+
entries = [
|
|
516
|
+
CodexFilesystemEntry.model_validate(item) for item in payload["entries"]
|
|
517
|
+
]
|
|
518
|
+
except (
|
|
519
|
+
KeyError,
|
|
520
|
+
TypeError,
|
|
521
|
+
ValueError,
|
|
522
|
+
UnicodeDecodeError,
|
|
523
|
+
json.JSONDecodeError,
|
|
524
|
+
) as exc:
|
|
525
|
+
raise RuntimeError(
|
|
526
|
+
"The remote host returned an invalid directory listing."
|
|
527
|
+
) from exc
|
|
528
|
+
return CodexFilesystemResponse(
|
|
529
|
+
path=str(payload.get("path") or requested_path),
|
|
530
|
+
parent_path=payload.get("parent_path"),
|
|
531
|
+
roots=[
|
|
532
|
+
CodexFilesystemEntry(
|
|
533
|
+
name="/",
|
|
534
|
+
path="/",
|
|
535
|
+
kind="directory",
|
|
536
|
+
readable=True,
|
|
537
|
+
)
|
|
538
|
+
],
|
|
539
|
+
entries=entries,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
async def list_skills(
|
|
543
|
+
self,
|
|
544
|
+
*,
|
|
545
|
+
thread_id: str | None = None,
|
|
546
|
+
host_id: str | None = None,
|
|
547
|
+
cwd: str | None = None,
|
|
548
|
+
force_reload: bool = False,
|
|
549
|
+
) -> list[JsonDict]:
|
|
550
|
+
session: CodexIpcSession
|
|
551
|
+
resolved_cwd = cwd.strip() if isinstance(cwd, str) and cwd.strip() else ""
|
|
552
|
+
if thread_id:
|
|
553
|
+
managed = await self._ensure_thread(thread_id, host_id=host_id)
|
|
554
|
+
session = managed.session
|
|
555
|
+
if not resolved_cwd:
|
|
556
|
+
state = managed.state or managed.session.state
|
|
557
|
+
state_cwd = state.get("cwd") if isinstance(state, dict) else None
|
|
558
|
+
if isinstance(state_cwd, str) and state_cwd.strip():
|
|
559
|
+
resolved_cwd = state_cwd.strip()
|
|
560
|
+
else:
|
|
561
|
+
session = await self._ensure_workspace_session(host_id or "local")
|
|
562
|
+
|
|
563
|
+
codex = await session._ensure_codex()
|
|
564
|
+
params: JsonDict = {}
|
|
565
|
+
if resolved_cwd:
|
|
566
|
+
params["cwds"] = [resolved_cwd]
|
|
567
|
+
if force_reload:
|
|
568
|
+
params["forceReload"] = True
|
|
569
|
+
response = await codex._client.request(
|
|
570
|
+
"skills/list",
|
|
571
|
+
params or None,
|
|
572
|
+
response_model=SkillsListResponse,
|
|
573
|
+
)
|
|
574
|
+
return self._flatten_skills_response(response)
|
|
575
|
+
|
|
576
|
+
async def start_thread(
|
|
577
|
+
self,
|
|
578
|
+
*,
|
|
579
|
+
project_path: str | None = None,
|
|
580
|
+
host_id: str = "local",
|
|
581
|
+
) -> JsonDict:
|
|
582
|
+
resolved_host_id = self._resolve_host_id(host_id)
|
|
583
|
+
params = self._thread_start_params(
|
|
584
|
+
project_path=project_path,
|
|
585
|
+
host_id=resolved_host_id,
|
|
586
|
+
)
|
|
587
|
+
session = self._new_session(self._config(host_id=resolved_host_id))
|
|
588
|
+
await session.start()
|
|
589
|
+
try:
|
|
590
|
+
await session.start_new_thread(params)
|
|
591
|
+
thread_id = session.thread_id
|
|
592
|
+
if not thread_id:
|
|
593
|
+
raise RuntimeError("Codex did not return a thread id.")
|
|
594
|
+
await self._register_session(thread_id, session)
|
|
595
|
+
try:
|
|
596
|
+
self.config_service.assign_codex_thread_project(
|
|
597
|
+
thread_id,
|
|
598
|
+
host_id=resolved_host_id,
|
|
599
|
+
cwd=str(params.get("cwd") or ""),
|
|
600
|
+
)
|
|
601
|
+
except OSError as exc:
|
|
602
|
+
logger.warning("Unable to persist project assignment: %s", exc)
|
|
603
|
+
except Exception:
|
|
604
|
+
await session.stop()
|
|
605
|
+
raise
|
|
606
|
+
return {
|
|
607
|
+
"thread_id": thread_id,
|
|
608
|
+
"host_id": resolved_host_id,
|
|
609
|
+
"state": self._state_with_host_id(session.state, resolved_host_id),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async def open_thread(
|
|
613
|
+
self,
|
|
614
|
+
thread_id: str,
|
|
615
|
+
*,
|
|
616
|
+
host_id: str | None = None,
|
|
617
|
+
) -> JsonDict:
|
|
618
|
+
managed = await self._ensure_thread(thread_id, host_id=host_id)
|
|
619
|
+
return {
|
|
620
|
+
"thread_id": managed.session.thread_id,
|
|
621
|
+
"state": self._state_with_host_id(
|
|
622
|
+
managed.state or managed.session.state,
|
|
623
|
+
managed.session.config.host_id,
|
|
624
|
+
),
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async def get_thread_state(
|
|
628
|
+
self,
|
|
629
|
+
thread_id: str,
|
|
630
|
+
*,
|
|
631
|
+
host_id: str | None = None,
|
|
632
|
+
) -> JsonDict | None:
|
|
633
|
+
managed = await self._ensure_thread(thread_id, host_id=host_id)
|
|
634
|
+
return self._state_with_host_id(
|
|
635
|
+
managed.state or managed.session.state,
|
|
636
|
+
managed.session.config.host_id,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def add_session_event_sink(self, sink: CodexSessionEventSink) -> Unsubscribe:
|
|
640
|
+
return self._session_events.add_sink(sink)
|
|
641
|
+
|
|
642
|
+
async def subscribe(
|
|
643
|
+
self,
|
|
644
|
+
thread_id: str,
|
|
645
|
+
queue: CodexSubscriberQueue,
|
|
646
|
+
*,
|
|
647
|
+
host_id: str | None = None,
|
|
648
|
+
) -> JsonDict | None:
|
|
649
|
+
first_subscription = self._session_events.subscribe_thread(thread_id, queue)
|
|
650
|
+
try:
|
|
651
|
+
managed = await self._ensure_thread(
|
|
652
|
+
thread_id,
|
|
653
|
+
host_id=host_id,
|
|
654
|
+
following=first_subscription,
|
|
655
|
+
)
|
|
656
|
+
except Exception:
|
|
657
|
+
self._session_events.unsubscribe_thread(thread_id, queue)
|
|
658
|
+
raise
|
|
659
|
+
state = self._state_with_host_id(
|
|
660
|
+
managed.state or managed.session.state,
|
|
661
|
+
managed.session.config.host_id,
|
|
662
|
+
)
|
|
663
|
+
await self._session_events.publish_to_thread_subscribers(
|
|
664
|
+
thread_id,
|
|
665
|
+
self._legacy_thread_event(
|
|
666
|
+
"thread_snapshot",
|
|
667
|
+
thread_id,
|
|
668
|
+
state=state,
|
|
669
|
+
session=managed.session,
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
await self._session_events.publish_to_thread_subscribers(
|
|
673
|
+
thread_id,
|
|
674
|
+
self._session_state_event(
|
|
675
|
+
thread_id,
|
|
676
|
+
state,
|
|
677
|
+
session=managed.session,
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
return state
|
|
681
|
+
|
|
682
|
+
async def unsubscribe(
|
|
683
|
+
self,
|
|
684
|
+
thread_id: str,
|
|
685
|
+
queue: CodexSubscriberQueue,
|
|
686
|
+
) -> None:
|
|
687
|
+
last_subscription = self._session_events.unsubscribe_thread(thread_id, queue)
|
|
688
|
+
managed = self._threads.get(thread_id)
|
|
689
|
+
if last_subscription and managed is not None:
|
|
690
|
+
try:
|
|
691
|
+
await managed.session.set_following(False)
|
|
692
|
+
except Exception as exc:
|
|
693
|
+
logger.warning(
|
|
694
|
+
"Unable to stop following Codex thread %s: %s",
|
|
695
|
+
thread_id,
|
|
696
|
+
exc,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
async def send_prompt(
|
|
700
|
+
self,
|
|
701
|
+
thread_id: str,
|
|
702
|
+
prompt: str,
|
|
703
|
+
*,
|
|
704
|
+
collaboration_mode: JsonDict | None = None,
|
|
705
|
+
attachments: list[JsonDict] | None = None,
|
|
706
|
+
approval_policy: str | None = None,
|
|
707
|
+
approvals_reviewer: str | None = None,
|
|
708
|
+
sandbox_policy: JsonDict | None = None,
|
|
709
|
+
) -> None:
|
|
710
|
+
managed = await self._ensure_thread(thread_id)
|
|
711
|
+
input_items = self._prompt_input_items(prompt.strip(), attachments or [])
|
|
712
|
+
await managed.session.run_prompt(
|
|
713
|
+
prompt.strip(),
|
|
714
|
+
wait_for_completion=False,
|
|
715
|
+
collaboration_mode=collaboration_mode,
|
|
716
|
+
input_items=input_items if attachments else None,
|
|
717
|
+
approval_policy=approval_policy,
|
|
718
|
+
approvals_reviewer=approvals_reviewer,
|
|
719
|
+
sandbox=sandbox_policy,
|
|
720
|
+
)
|
|
721
|
+
if collaboration_mode is not None:
|
|
722
|
+
await self._apply_latest_collaboration_mode(
|
|
723
|
+
thread_id,
|
|
724
|
+
managed,
|
|
725
|
+
collaboration_mode,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
def _prompt_input_items(
|
|
729
|
+
self,
|
|
730
|
+
prompt: str,
|
|
731
|
+
attachments: list[JsonDict],
|
|
732
|
+
) -> list[JsonDict]:
|
|
733
|
+
items: list[JsonDict] = [{"type": "text", "text": prompt, "text_elements": []}]
|
|
734
|
+
for attachment in attachments:
|
|
735
|
+
item_type = attachment.get("type")
|
|
736
|
+
if item_type in {"image", "input_image"}:
|
|
737
|
+
image_url = (
|
|
738
|
+
attachment.get("imageUrl")
|
|
739
|
+
or attachment.get("image_url")
|
|
740
|
+
or attachment.get("url")
|
|
741
|
+
or attachment.get("src")
|
|
742
|
+
)
|
|
743
|
+
if isinstance(image_url, str) and image_url.strip():
|
|
744
|
+
items.append(
|
|
745
|
+
{
|
|
746
|
+
"type": "image",
|
|
747
|
+
"url": image_url.strip(),
|
|
748
|
+
"detail": "auto",
|
|
749
|
+
}
|
|
750
|
+
)
|
|
751
|
+
continue
|
|
752
|
+
if item_type in {"mention", "file"}:
|
|
753
|
+
path = attachment.get("path") or attachment.get("fsPath")
|
|
754
|
+
name = attachment.get("name") or attachment.get("label")
|
|
755
|
+
if isinstance(path, str) and path.strip():
|
|
756
|
+
clean_path = path.strip()
|
|
757
|
+
items.append(
|
|
758
|
+
{
|
|
759
|
+
"type": "mention",
|
|
760
|
+
"name": (
|
|
761
|
+
name.strip()
|
|
762
|
+
if isinstance(name, str) and name.strip()
|
|
763
|
+
else Path(clean_path).name
|
|
764
|
+
),
|
|
765
|
+
"path": clean_path,
|
|
766
|
+
}
|
|
767
|
+
)
|
|
768
|
+
continue
|
|
769
|
+
if item_type == "skill":
|
|
770
|
+
path = attachment.get("path") or attachment.get("fsPath")
|
|
771
|
+
name = attachment.get("name") or attachment.get("label")
|
|
772
|
+
if (
|
|
773
|
+
isinstance(path, str)
|
|
774
|
+
and path.strip()
|
|
775
|
+
and isinstance(name, str)
|
|
776
|
+
and name.strip()
|
|
777
|
+
):
|
|
778
|
+
items.append(
|
|
779
|
+
{
|
|
780
|
+
"type": "skill",
|
|
781
|
+
"name": name.strip(),
|
|
782
|
+
"path": path.strip(),
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
return items
|
|
786
|
+
|
|
787
|
+
async def steer_prompt(self, thread_id: str, prompt: str) -> None:
|
|
788
|
+
managed = await self._ensure_thread(thread_id)
|
|
789
|
+
await managed.session.steer_prompt(prompt.strip())
|
|
790
|
+
|
|
791
|
+
async def interrupt_turn(self, thread_id: str, turn_id: str | None = None) -> bool:
|
|
792
|
+
managed = await self._ensure_thread(thread_id)
|
|
793
|
+
return await managed.session.interrupt_turn(turn_id)
|
|
794
|
+
|
|
795
|
+
async def compact_thread(self, thread_id: str) -> bool:
|
|
796
|
+
managed = await self._ensure_thread(thread_id)
|
|
797
|
+
return await managed.session.compact_thread()
|
|
798
|
+
|
|
799
|
+
async def set_thread_goal(
|
|
800
|
+
self,
|
|
801
|
+
thread_id: str,
|
|
802
|
+
*,
|
|
803
|
+
objective: str | None = None,
|
|
804
|
+
status: str | None = None,
|
|
805
|
+
token_budget: int | None = None,
|
|
806
|
+
) -> JsonDict:
|
|
807
|
+
managed = await self._ensure_thread(thread_id)
|
|
808
|
+
response = await managed.session.set_thread_goal(
|
|
809
|
+
objective=objective,
|
|
810
|
+
status=status,
|
|
811
|
+
token_budget=token_budget,
|
|
812
|
+
)
|
|
813
|
+
latest_state = managed.session.state
|
|
814
|
+
if isinstance(latest_state, dict):
|
|
815
|
+
managed.state = latest_state
|
|
816
|
+
await self._fanout_thread_state(
|
|
817
|
+
thread_id,
|
|
818
|
+
latest_state,
|
|
819
|
+
session=managed.session,
|
|
820
|
+
)
|
|
821
|
+
return response
|
|
822
|
+
|
|
823
|
+
async def get_thread_goal(self, thread_id: str) -> JsonDict | None:
|
|
824
|
+
managed = await self._ensure_thread(thread_id)
|
|
825
|
+
goal = await managed.session.get_thread_goal()
|
|
826
|
+
latest_state = managed.session.state
|
|
827
|
+
if isinstance(latest_state, dict):
|
|
828
|
+
managed.state = latest_state
|
|
829
|
+
await self._fanout_thread_state(
|
|
830
|
+
thread_id,
|
|
831
|
+
latest_state,
|
|
832
|
+
session=managed.session,
|
|
833
|
+
)
|
|
834
|
+
return goal
|
|
835
|
+
|
|
836
|
+
async def clear_thread_goal(self, thread_id: str) -> JsonDict:
|
|
837
|
+
managed = await self._ensure_thread(thread_id)
|
|
838
|
+
response = await managed.session.clear_thread_goal()
|
|
839
|
+
latest_state = managed.session.state
|
|
840
|
+
if isinstance(latest_state, dict):
|
|
841
|
+
managed.state = latest_state
|
|
842
|
+
await self._fanout_thread_state(
|
|
843
|
+
thread_id,
|
|
844
|
+
latest_state,
|
|
845
|
+
session=managed.session,
|
|
846
|
+
)
|
|
847
|
+
return response
|
|
848
|
+
|
|
849
|
+
async def set_collaboration_mode(
|
|
850
|
+
self,
|
|
851
|
+
thread_id: str,
|
|
852
|
+
collaboration_mode: JsonDict | None,
|
|
853
|
+
) -> None:
|
|
854
|
+
managed = await self._ensure_thread(thread_id)
|
|
855
|
+
await managed.session.set_collaboration_mode(collaboration_mode)
|
|
856
|
+
await self._apply_latest_collaboration_mode(
|
|
857
|
+
thread_id,
|
|
858
|
+
managed,
|
|
859
|
+
collaboration_mode,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
async def submit_user_input_response(
|
|
863
|
+
self,
|
|
864
|
+
thread_id: str,
|
|
865
|
+
request_id: str,
|
|
866
|
+
response: JsonDict,
|
|
867
|
+
) -> bool:
|
|
868
|
+
managed = await self._ensure_thread(thread_id)
|
|
869
|
+
return await managed.session.submit_user_input_response(request_id, response)
|
|
870
|
+
|
|
871
|
+
async def enqueue_followup(self, thread_id: str, prompt: str) -> JsonDict:
|
|
872
|
+
managed = await self._ensure_thread(thread_id)
|
|
873
|
+
return await managed.session.enqueue_followup(prompt.strip())
|
|
874
|
+
|
|
875
|
+
async def remove_followup(self, thread_id: str, message_id: str) -> None:
|
|
876
|
+
managed = await self._ensure_thread(thread_id)
|
|
877
|
+
await managed.session.remove_followup(message_id)
|
|
878
|
+
|
|
879
|
+
async def rename_thread(self, thread_id: str, name: str) -> None:
|
|
880
|
+
managed = await self._ensure_thread(thread_id)
|
|
881
|
+
await managed.session.set_thread_name(name.strip(), thread_id)
|
|
882
|
+
|
|
883
|
+
async def archive_thread(self, thread_id: str) -> None:
|
|
884
|
+
managed = await self._ensure_thread(thread_id)
|
|
885
|
+
cwd = None
|
|
886
|
+
state = managed.state or managed.session.state
|
|
887
|
+
if isinstance(state, dict) and isinstance(state.get("cwd"), str):
|
|
888
|
+
cwd = state["cwd"]
|
|
889
|
+
await managed.session.archive_thread(thread_id, cwd=cwd)
|
|
890
|
+
await self._broadcast_thread_event("thread_archived", thread_id)
|
|
891
|
+
await self._close_thread(thread_id)
|
|
892
|
+
|
|
893
|
+
async def fork_thread(
|
|
894
|
+
self,
|
|
895
|
+
thread_id: str,
|
|
896
|
+
*,
|
|
897
|
+
host_id: str | None = None,
|
|
898
|
+
) -> JsonDict:
|
|
899
|
+
source_thread_id = thread_id.strip()
|
|
900
|
+
if not source_thread_id:
|
|
901
|
+
raise ValueError("thread_id is required.")
|
|
902
|
+
|
|
903
|
+
source = await self._ensure_thread(source_thread_id, host_id=host_id)
|
|
904
|
+
source_host_id = source.session.config.host_id or "local"
|
|
905
|
+
session = self._new_session(
|
|
906
|
+
self._config(
|
|
907
|
+
host_id=source_host_id,
|
|
908
|
+
thread_id=source_thread_id,
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
await session.start()
|
|
912
|
+
try:
|
|
913
|
+
await session.fork_thread(source_thread_id)
|
|
914
|
+
forked_thread_id = session.thread_id
|
|
915
|
+
if not forked_thread_id:
|
|
916
|
+
raise RuntimeError("Codex did not return a forked thread id.")
|
|
917
|
+
managed = await self._register_session(forked_thread_id, session)
|
|
918
|
+
try:
|
|
919
|
+
self.config_service.copy_codex_thread_assignment(
|
|
920
|
+
source_thread_id,
|
|
921
|
+
forked_thread_id,
|
|
922
|
+
)
|
|
923
|
+
except OSError as exc:
|
|
924
|
+
logger.warning("Unable to persist fork project assignment: %s", exc)
|
|
925
|
+
except Exception:
|
|
926
|
+
await session.stop()
|
|
927
|
+
raise
|
|
928
|
+
return {
|
|
929
|
+
"thread_id": forked_thread_id,
|
|
930
|
+
"host_id": source_host_id,
|
|
931
|
+
"state": self._state_with_host_id(
|
|
932
|
+
managed.state or managed.session.state,
|
|
933
|
+
source_host_id,
|
|
934
|
+
),
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async def unarchive_thread(
|
|
938
|
+
self,
|
|
939
|
+
thread_id: str,
|
|
940
|
+
*,
|
|
941
|
+
host_id: str | None = None,
|
|
942
|
+
) -> None:
|
|
943
|
+
resolved_host_id = self._resolve_thread_host(thread_id, host_id)
|
|
944
|
+
session = await self._ensure_workspace_session(resolved_host_id)
|
|
945
|
+
await session.unarchive_thread(thread_id)
|
|
946
|
+
await self._broadcast_thread_event("thread_unarchived", thread_id)
|
|
947
|
+
|
|
948
|
+
async def _ensure_workspace_session(self, host_id: str) -> CodexIpcSession:
|
|
949
|
+
resolved_host_id = self._resolve_host_id(host_id)
|
|
950
|
+
existing = self._workspace_sessions.get(resolved_host_id)
|
|
951
|
+
if existing is not None:
|
|
952
|
+
return existing
|
|
953
|
+
session = self._new_session(self._config(host_id=resolved_host_id))
|
|
954
|
+
self._workspace_sessions[resolved_host_id] = session
|
|
955
|
+
try:
|
|
956
|
+
await session.start()
|
|
957
|
+
except Exception:
|
|
958
|
+
self._workspace_sessions.pop(resolved_host_id, None)
|
|
959
|
+
with contextlib.suppress(Exception):
|
|
960
|
+
await session.stop()
|
|
961
|
+
raise
|
|
962
|
+
return session
|
|
963
|
+
|
|
964
|
+
async def _close_workspace_session(self, host_id: str) -> None:
|
|
965
|
+
session = self._workspace_sessions.pop(host_id, None)
|
|
966
|
+
if session is not None:
|
|
967
|
+
with contextlib.suppress(Exception):
|
|
968
|
+
await session.stop()
|
|
969
|
+
|
|
970
|
+
def _flatten_skills_response(self, response: SkillsListResponse) -> list[JsonDict]:
|
|
971
|
+
skills: list[JsonDict] = []
|
|
972
|
+
seen_paths: set[str] = set()
|
|
973
|
+
for entry in response.data:
|
|
974
|
+
for skill in entry.skills:
|
|
975
|
+
if not skill.enabled:
|
|
976
|
+
continue
|
|
977
|
+
path = str(
|
|
978
|
+
skill.path.root if hasattr(skill.path, "root") else skill.path
|
|
979
|
+
)
|
|
980
|
+
if not path or path in seen_paths:
|
|
981
|
+
continue
|
|
982
|
+
seen_paths.add(path)
|
|
983
|
+
interface = (
|
|
984
|
+
skill.interface.model_dump(mode="json", by_alias=True)
|
|
985
|
+
if skill.interface is not None
|
|
986
|
+
else None
|
|
987
|
+
)
|
|
988
|
+
short_description = skill.short_description
|
|
989
|
+
if not short_description and skill.interface is not None:
|
|
990
|
+
short_description = skill.interface.short_description
|
|
991
|
+
display_name = skill.name
|
|
992
|
+
if skill.interface is not None and skill.interface.display_name:
|
|
993
|
+
display_name = skill.interface.display_name
|
|
994
|
+
skills.append(
|
|
995
|
+
{
|
|
996
|
+
"name": skill.name,
|
|
997
|
+
"display_name": display_name,
|
|
998
|
+
"description": skill.description,
|
|
999
|
+
"short_description": short_description,
|
|
1000
|
+
"path": path,
|
|
1001
|
+
"scope": skill.scope.value
|
|
1002
|
+
if hasattr(skill.scope, "value")
|
|
1003
|
+
else str(skill.scope),
|
|
1004
|
+
"enabled": skill.enabled,
|
|
1005
|
+
"interface": interface,
|
|
1006
|
+
"cwd": entry.cwd,
|
|
1007
|
+
}
|
|
1008
|
+
)
|
|
1009
|
+
skills.sort(
|
|
1010
|
+
key=lambda item: (
|
|
1011
|
+
str(item.get("scope") or ""),
|
|
1012
|
+
str(item.get("display_name") or item.get("name") or "").lower(),
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
return skills
|
|
1016
|
+
|
|
1017
|
+
async def _ensure_thread(
|
|
1018
|
+
self,
|
|
1019
|
+
thread_id: str,
|
|
1020
|
+
*,
|
|
1021
|
+
host_id: str | None = None,
|
|
1022
|
+
following: bool = False,
|
|
1023
|
+
) -> ManagedCodexThread:
|
|
1024
|
+
normalized_thread_id = thread_id.strip()
|
|
1025
|
+
if not normalized_thread_id:
|
|
1026
|
+
raise ValueError("thread_id is required.")
|
|
1027
|
+
managed = self._threads.get(normalized_thread_id)
|
|
1028
|
+
if managed is not None:
|
|
1029
|
+
if following:
|
|
1030
|
+
await managed.session.set_following(True)
|
|
1031
|
+
return managed
|
|
1032
|
+
|
|
1033
|
+
async with self._lock:
|
|
1034
|
+
managed = self._threads.get(normalized_thread_id)
|
|
1035
|
+
if managed is not None:
|
|
1036
|
+
if following:
|
|
1037
|
+
await managed.session.set_following(True)
|
|
1038
|
+
return managed
|
|
1039
|
+
resolved_host_id = self._resolve_thread_host(
|
|
1040
|
+
normalized_thread_id,
|
|
1041
|
+
host_id,
|
|
1042
|
+
)
|
|
1043
|
+
session = self._new_session(
|
|
1044
|
+
self._config(
|
|
1045
|
+
host_id=resolved_host_id,
|
|
1046
|
+
thread_id=normalized_thread_id,
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
await session.start()
|
|
1050
|
+
try:
|
|
1051
|
+
if following:
|
|
1052
|
+
await session.set_following(True)
|
|
1053
|
+
await session.hydrate_initial_state()
|
|
1054
|
+
except Exception:
|
|
1055
|
+
await session.stop()
|
|
1056
|
+
raise
|
|
1057
|
+
return await self._register_session(normalized_thread_id, session)
|
|
1058
|
+
|
|
1059
|
+
async def _register_session(
|
|
1060
|
+
self,
|
|
1061
|
+
thread_id: str,
|
|
1062
|
+
session: CodexIpcSession,
|
|
1063
|
+
) -> ManagedCodexThread:
|
|
1064
|
+
existing = self._threads.get(thread_id)
|
|
1065
|
+
if existing is not None:
|
|
1066
|
+
if existing.session is not session:
|
|
1067
|
+
await session.stop()
|
|
1068
|
+
return existing
|
|
1069
|
+
|
|
1070
|
+
watcher_task = asyncio.create_task(
|
|
1071
|
+
self._watch_thread(thread_id, session),
|
|
1072
|
+
name=f"open-codex-bridge-watch:{thread_id}",
|
|
1073
|
+
)
|
|
1074
|
+
event_watcher_task = None
|
|
1075
|
+
if self._session_has_native_events(session):
|
|
1076
|
+
event_watcher_task = asyncio.create_task(
|
|
1077
|
+
self._watch_session_events(thread_id, session),
|
|
1078
|
+
name=f"open-codex-bridge-events:{thread_id}",
|
|
1079
|
+
)
|
|
1080
|
+
managed = ManagedCodexThread(
|
|
1081
|
+
session=session,
|
|
1082
|
+
watcher_task=watcher_task,
|
|
1083
|
+
event_watcher_task=event_watcher_task,
|
|
1084
|
+
state=session.state,
|
|
1085
|
+
)
|
|
1086
|
+
self._threads[thread_id] = managed
|
|
1087
|
+
self._thread_hosts[thread_id] = session.config.host_id or "local"
|
|
1088
|
+
if session.state is not None:
|
|
1089
|
+
await self._fanout_thread_state(thread_id, session.state, session=session)
|
|
1090
|
+
return managed
|
|
1091
|
+
|
|
1092
|
+
async def _close_thread(self, thread_id: str) -> None:
|
|
1093
|
+
managed = self._threads.pop(thread_id, None)
|
|
1094
|
+
if managed is None:
|
|
1095
|
+
return
|
|
1096
|
+
self._cancel_managed_watchers(managed)
|
|
1097
|
+
await self._wait_managed_watchers(managed)
|
|
1098
|
+
await managed.session.stop()
|
|
1099
|
+
self._session_events.clear_thread(thread_id)
|
|
1100
|
+
|
|
1101
|
+
async def _restart_host_sessions(self, host_id: str) -> None:
|
|
1102
|
+
managed_threads = [
|
|
1103
|
+
(thread_id, managed)
|
|
1104
|
+
for thread_id, managed in self._threads.items()
|
|
1105
|
+
if (managed.session.config.host_id or "local") == host_id
|
|
1106
|
+
]
|
|
1107
|
+
for _, managed in managed_threads:
|
|
1108
|
+
self._cancel_managed_watchers(managed)
|
|
1109
|
+
for thread_id, managed in managed_threads:
|
|
1110
|
+
await self._wait_managed_watchers(managed)
|
|
1111
|
+
await managed.session.stop()
|
|
1112
|
+
self._threads.pop(thread_id, None)
|
|
1113
|
+
self._session_events.clear_thread(thread_id)
|
|
1114
|
+
await self._close_workspace_session(host_id)
|
|
1115
|
+
|
|
1116
|
+
async def _watch_thread(
|
|
1117
|
+
self,
|
|
1118
|
+
thread_id: str,
|
|
1119
|
+
session: CodexIpcSession,
|
|
1120
|
+
) -> None:
|
|
1121
|
+
async for state in session.watch_state(replay=True):
|
|
1122
|
+
managed = self._threads.get(thread_id)
|
|
1123
|
+
if managed is not None:
|
|
1124
|
+
managed.state = state
|
|
1125
|
+
await self._fanout_thread_state(thread_id, state, session=session)
|
|
1126
|
+
|
|
1127
|
+
async def _watch_session_events(
|
|
1128
|
+
self,
|
|
1129
|
+
thread_id: str,
|
|
1130
|
+
session: CodexIpcSession,
|
|
1131
|
+
) -> None:
|
|
1132
|
+
async for event in session.watch_session_events(): # type: ignore[attr-defined]
|
|
1133
|
+
payload = self._native_session_event(thread_id, event)
|
|
1134
|
+
await self._session_events.publish_thread_event(thread_id, payload)
|
|
1135
|
+
|
|
1136
|
+
def _session_has_native_events(self, session: CodexIpcSession) -> bool:
|
|
1137
|
+
return callable(getattr(session, "watch_session_events", None))
|
|
1138
|
+
|
|
1139
|
+
def _cancel_managed_watchers(self, managed: ManagedCodexThread) -> None:
|
|
1140
|
+
managed.watcher_task.cancel()
|
|
1141
|
+
if managed.event_watcher_task is not None:
|
|
1142
|
+
managed.event_watcher_task.cancel()
|
|
1143
|
+
|
|
1144
|
+
async def _wait_managed_watchers(self, managed: ManagedCodexThread) -> None:
|
|
1145
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1146
|
+
await managed.watcher_task
|
|
1147
|
+
if managed.event_watcher_task is not None:
|
|
1148
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1149
|
+
await managed.event_watcher_task
|
|
1150
|
+
|
|
1151
|
+
async def _fanout_thread_state(
|
|
1152
|
+
self,
|
|
1153
|
+
thread_id: str,
|
|
1154
|
+
state: JsonDict | None,
|
|
1155
|
+
*,
|
|
1156
|
+
session: CodexIpcSession,
|
|
1157
|
+
) -> None:
|
|
1158
|
+
state_with_host = self._state_with_host_id(state, session.config.host_id)
|
|
1159
|
+
payload = {
|
|
1160
|
+
"thread_id": thread_id,
|
|
1161
|
+
"state": state_with_host,
|
|
1162
|
+
"stream_role": session.stream_role,
|
|
1163
|
+
"queued_followups": session.queued_followups,
|
|
1164
|
+
}
|
|
1165
|
+
await self._session_events.publish_to_thread_subscribers(
|
|
1166
|
+
thread_id,
|
|
1167
|
+
{
|
|
1168
|
+
"type": "thread_state",
|
|
1169
|
+
"payload": payload,
|
|
1170
|
+
},
|
|
1171
|
+
)
|
|
1172
|
+
if not self._session_has_native_events(session):
|
|
1173
|
+
await self._session_events.publish_thread_event(
|
|
1174
|
+
thread_id,
|
|
1175
|
+
self._session_state_event(
|
|
1176
|
+
thread_id,
|
|
1177
|
+
state_with_host,
|
|
1178
|
+
session=session,
|
|
1179
|
+
),
|
|
1180
|
+
)
|
|
1181
|
+
await self.event_broker.publish(
|
|
1182
|
+
"codex_thread_state",
|
|
1183
|
+
payload,
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
def _legacy_thread_event(
|
|
1187
|
+
self,
|
|
1188
|
+
event_type: str,
|
|
1189
|
+
thread_id: str,
|
|
1190
|
+
*,
|
|
1191
|
+
state: JsonDict | None,
|
|
1192
|
+
session: CodexIpcSession,
|
|
1193
|
+
) -> CodexSessionEvent:
|
|
1194
|
+
return {
|
|
1195
|
+
"type": event_type,
|
|
1196
|
+
"payload": {
|
|
1197
|
+
"thread_id": thread_id,
|
|
1198
|
+
"state": state,
|
|
1199
|
+
"stream_role": session.stream_role,
|
|
1200
|
+
"queued_followups": session.queued_followups,
|
|
1201
|
+
},
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
def _session_state_event(
|
|
1205
|
+
self,
|
|
1206
|
+
thread_id: str,
|
|
1207
|
+
state: JsonDict | None,
|
|
1208
|
+
*,
|
|
1209
|
+
session: CodexIpcSession,
|
|
1210
|
+
) -> CodexSessionEvent:
|
|
1211
|
+
return {
|
|
1212
|
+
"type": "codex_session_event",
|
|
1213
|
+
"payload": {
|
|
1214
|
+
"thread_id": thread_id,
|
|
1215
|
+
"method": "thread-stream-state-changed",
|
|
1216
|
+
"params": {
|
|
1217
|
+
"conversationId": thread_id,
|
|
1218
|
+
"type": "snapshot",
|
|
1219
|
+
"state": state,
|
|
1220
|
+
"streamRole": session.stream_role,
|
|
1221
|
+
"queuedFollowups": session.queued_followups,
|
|
1222
|
+
},
|
|
1223
|
+
},
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
def _native_session_event(
|
|
1227
|
+
self,
|
|
1228
|
+
thread_id: str,
|
|
1229
|
+
event: JsonDict,
|
|
1230
|
+
) -> CodexSessionEvent:
|
|
1231
|
+
params = event.get("params")
|
|
1232
|
+
return {
|
|
1233
|
+
"type": "codex_session_event",
|
|
1234
|
+
"payload": {
|
|
1235
|
+
"thread_id": thread_id,
|
|
1236
|
+
"method": event.get("method"),
|
|
1237
|
+
"params": params if isinstance(params, dict) else {},
|
|
1238
|
+
"source_client_id": event.get("sourceClientId"),
|
|
1239
|
+
"version": event.get("version"),
|
|
1240
|
+
},
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async def _publish_session_event_to_broker(
|
|
1244
|
+
self,
|
|
1245
|
+
event: CodexSessionEvent,
|
|
1246
|
+
) -> None:
|
|
1247
|
+
if event.get("type") != "codex_session_event":
|
|
1248
|
+
return
|
|
1249
|
+
payload = event.get("payload")
|
|
1250
|
+
await self.event_broker.publish(
|
|
1251
|
+
"codex_session_event",
|
|
1252
|
+
payload if isinstance(payload, dict) else {},
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
def _state_with_host_id(
|
|
1256
|
+
self,
|
|
1257
|
+
state: JsonDict | None,
|
|
1258
|
+
host_id: str,
|
|
1259
|
+
) -> JsonDict | None:
|
|
1260
|
+
if not isinstance(state, dict):
|
|
1261
|
+
return state
|
|
1262
|
+
materialized = materialize_conversation_state(state)
|
|
1263
|
+
if not isinstance(materialized, dict):
|
|
1264
|
+
return materialized
|
|
1265
|
+
if isinstance(materialized.get("hostId"), str) and materialized["hostId"]:
|
|
1266
|
+
return materialized
|
|
1267
|
+
return {**materialized, "hostId": host_id or "local"}
|
|
1268
|
+
|
|
1269
|
+
async def _apply_latest_collaboration_mode(
|
|
1270
|
+
self,
|
|
1271
|
+
thread_id: str,
|
|
1272
|
+
managed: ManagedCodexThread,
|
|
1273
|
+
collaboration_mode: JsonDict | None,
|
|
1274
|
+
) -> None:
|
|
1275
|
+
latest_state = managed.session.state
|
|
1276
|
+
if isinstance(latest_state, dict):
|
|
1277
|
+
managed.state = latest_state
|
|
1278
|
+
elif isinstance(managed.state, dict):
|
|
1279
|
+
latest_state = managed.state
|
|
1280
|
+
|
|
1281
|
+
if not isinstance(latest_state, dict):
|
|
1282
|
+
return
|
|
1283
|
+
|
|
1284
|
+
latest_state["latestCollaborationMode"] = (
|
|
1285
|
+
dict(collaboration_mode)
|
|
1286
|
+
if isinstance(collaboration_mode, dict)
|
|
1287
|
+
else {
|
|
1288
|
+
"mode": "default",
|
|
1289
|
+
"settings": {
|
|
1290
|
+
"model": latest_state.get("latestModel") or "",
|
|
1291
|
+
"reasoning_effort": latest_state.get("latestReasoningEffort"),
|
|
1292
|
+
"developer_instructions": None,
|
|
1293
|
+
},
|
|
1294
|
+
}
|
|
1295
|
+
)
|
|
1296
|
+
await self._fanout_thread_state(
|
|
1297
|
+
thread_id,
|
|
1298
|
+
latest_state,
|
|
1299
|
+
session=managed.session,
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
async def _broadcast_thread_event(self, event_type: str, thread_id: str) -> None:
|
|
1303
|
+
payload = {
|
|
1304
|
+
"type": event_type,
|
|
1305
|
+
"payload": {
|
|
1306
|
+
"thread_id": thread_id,
|
|
1307
|
+
},
|
|
1308
|
+
}
|
|
1309
|
+
await self._session_events.publish_to_all_thread_subscribers(payload)
|
|
1310
|
+
await self._session_events.publish_global_event(
|
|
1311
|
+
{
|
|
1312
|
+
"type": "codex_session_event",
|
|
1313
|
+
"payload": {
|
|
1314
|
+
"thread_id": thread_id,
|
|
1315
|
+
"method": event_type,
|
|
1316
|
+
"params": {
|
|
1317
|
+
"threadId": thread_id,
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
}
|
|
1321
|
+
)
|
|
1322
|
+
await self.event_broker.publish(event_type, payload["payload"])
|
|
1323
|
+
|
|
1324
|
+
def _config(
|
|
1325
|
+
self,
|
|
1326
|
+
*,
|
|
1327
|
+
host_id: str = "local",
|
|
1328
|
+
thread_id: str | None = None,
|
|
1329
|
+
) -> CodexIpcConfig:
|
|
1330
|
+
settings = self.config_service.load_web_settings().codex
|
|
1331
|
+
resolved_host_id = self._resolve_host_id(host_id, settings=settings)
|
|
1332
|
+
codex_home = _codex_home(self.config_service.home_dir)
|
|
1333
|
+
codex_config = _load_codex_home_config(codex_home)
|
|
1334
|
+
is_remote = resolved_host_id != "local"
|
|
1335
|
+
return CodexIpcConfig(
|
|
1336
|
+
thread_id=thread_id,
|
|
1337
|
+
host_id=resolved_host_id,
|
|
1338
|
+
client_type="yier",
|
|
1339
|
+
model=None if is_remote else self._thread_model(settings, codex_config),
|
|
1340
|
+
reasoning_effort=(
|
|
1341
|
+
None if is_remote else self._reasoning_effort(settings, codex_config)
|
|
1342
|
+
),
|
|
1343
|
+
app_server_config=self._app_server_config(
|
|
1344
|
+
settings,
|
|
1345
|
+
host_id=resolved_host_id,
|
|
1346
|
+
codex_home=codex_home,
|
|
1347
|
+
),
|
|
1348
|
+
default_thread_params=self._default_thread_params(
|
|
1349
|
+
settings,
|
|
1350
|
+
codex_config,
|
|
1351
|
+
cwd=self._default_thread_cwd(settings, resolved_host_id),
|
|
1352
|
+
include_model=not is_remote,
|
|
1353
|
+
),
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
def _new_session(self, config: CodexIpcConfig) -> CodexIpcSession:
|
|
1357
|
+
return self._session_factory(config, notify=self._notify)
|
|
1358
|
+
|
|
1359
|
+
def _app_server_config(
|
|
1360
|
+
self,
|
|
1361
|
+
settings: StoredCodexSettings,
|
|
1362
|
+
*,
|
|
1363
|
+
host_id: str,
|
|
1364
|
+
codex_home: Path,
|
|
1365
|
+
) -> AppServerConfig:
|
|
1366
|
+
remote_connection = self._connection_for_host(host_id, settings=settings)
|
|
1367
|
+
if remote_connection is not None:
|
|
1368
|
+
return self._remote_app_server_config(
|
|
1369
|
+
remote_connection,
|
|
1370
|
+
cwd=None,
|
|
1371
|
+
client_name="open_codex_ui",
|
|
1372
|
+
client_title=f"Open Codex UI ({remote_connection.display_name})",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
command = settings.launcher_command or "codex app-server --listen stdio://"
|
|
1376
|
+
try:
|
|
1377
|
+
args = tuple(shlex.split(command))
|
|
1378
|
+
except ValueError:
|
|
1379
|
+
args = ("codex", "app-server", "--listen", "stdio://")
|
|
1380
|
+
if not args:
|
|
1381
|
+
args = ("codex", "app-server", "--listen", "stdio://")
|
|
1382
|
+
return AppServerConfig(
|
|
1383
|
+
launch_args_override=args,
|
|
1384
|
+
cwd=str(self.config_service.project_root),
|
|
1385
|
+
env={"CODEX_HOME": str(codex_home)},
|
|
1386
|
+
client_name="open_codex_ui",
|
|
1387
|
+
client_title="Open Codex UI",
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
def _remote_app_server_config(
|
|
1391
|
+
self,
|
|
1392
|
+
connection: CodexRemoteConnection,
|
|
1393
|
+
*,
|
|
1394
|
+
cwd: str | None = None,
|
|
1395
|
+
client_name: str = "open_codex_ui",
|
|
1396
|
+
client_title: str | None = None,
|
|
1397
|
+
) -> AppServerConfig:
|
|
1398
|
+
return AppServerConfig(
|
|
1399
|
+
ssh_websocket=SshWebsocketAppServerConfig(
|
|
1400
|
+
connection=SshConnectionConfig(
|
|
1401
|
+
host=self._ssh_target(connection),
|
|
1402
|
+
alias=connection.ssh_alias or None,
|
|
1403
|
+
port=connection.ssh_port,
|
|
1404
|
+
identity=connection.identity_file or None,
|
|
1405
|
+
),
|
|
1406
|
+
remote_cwd=connection.remote_path or "~",
|
|
1407
|
+
),
|
|
1408
|
+
cwd=cwd,
|
|
1409
|
+
client_name=client_name,
|
|
1410
|
+
client_title=client_title or f"Open Codex UI ({connection.display_name})",
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
def _connection_for_host(
|
|
1414
|
+
self,
|
|
1415
|
+
host_id: str,
|
|
1416
|
+
*,
|
|
1417
|
+
settings: StoredCodexSettings | None = None,
|
|
1418
|
+
) -> CodexRemoteConnection | None:
|
|
1419
|
+
connection_id = self._connection_id_from_host(host_id)
|
|
1420
|
+
if not connection_id:
|
|
1421
|
+
return None
|
|
1422
|
+
resolved_settings = settings or self.config_service.load_web_settings().codex
|
|
1423
|
+
for connection in resolved_settings.remote_connections:
|
|
1424
|
+
if connection.id == connection_id:
|
|
1425
|
+
return connection
|
|
1426
|
+
raise ValueError(f"Unknown Codex host: {host_id}")
|
|
1427
|
+
|
|
1428
|
+
def _resolve_host_id(
|
|
1429
|
+
self,
|
|
1430
|
+
host_id: str | None,
|
|
1431
|
+
*,
|
|
1432
|
+
settings: StoredCodexSettings | None = None,
|
|
1433
|
+
) -> str:
|
|
1434
|
+
normalized = host_id.strip() if isinstance(host_id, str) else ""
|
|
1435
|
+
if not normalized or normalized == "local":
|
|
1436
|
+
return "local"
|
|
1437
|
+
resolved = (
|
|
1438
|
+
normalized
|
|
1439
|
+
if normalized.startswith("ssh:")
|
|
1440
|
+
else self._host_id_for_connection(normalized)
|
|
1441
|
+
)
|
|
1442
|
+
self._connection_for_host(resolved, settings=settings)
|
|
1443
|
+
return resolved
|
|
1444
|
+
|
|
1445
|
+
def _resolve_thread_host(
|
|
1446
|
+
self,
|
|
1447
|
+
thread_id: str,
|
|
1448
|
+
host_id: str | None,
|
|
1449
|
+
) -> str:
|
|
1450
|
+
if host_id:
|
|
1451
|
+
return self._resolve_host_id(host_id)
|
|
1452
|
+
return self._thread_hosts.get(thread_id, "local")
|
|
1453
|
+
|
|
1454
|
+
def _host_id_for_connection(self, connection_id: str) -> str:
|
|
1455
|
+
return f"ssh:{connection_id.strip()}"
|
|
1456
|
+
|
|
1457
|
+
def _connection_id_from_host(self, host_id: str) -> str:
|
|
1458
|
+
return host_id.removeprefix("ssh:") if host_id.startswith("ssh:") else ""
|
|
1459
|
+
|
|
1460
|
+
def _remote_connection_by_id(
|
|
1461
|
+
self,
|
|
1462
|
+
connection_id: str,
|
|
1463
|
+
) -> CodexRemoteConnection | None:
|
|
1464
|
+
normalized_id = connection_id.strip()
|
|
1465
|
+
if not normalized_id:
|
|
1466
|
+
return None
|
|
1467
|
+
for (
|
|
1468
|
+
connection
|
|
1469
|
+
) in self.config_service.load_web_settings().codex.remote_connections:
|
|
1470
|
+
if connection.id == normalized_id:
|
|
1471
|
+
return connection
|
|
1472
|
+
return None
|
|
1473
|
+
|
|
1474
|
+
def _remote_statuses_for(
|
|
1475
|
+
self,
|
|
1476
|
+
settings: StoredCodexSettings,
|
|
1477
|
+
) -> dict[str, CodexRemoteConnectionStatus]:
|
|
1478
|
+
statuses: dict[str, CodexRemoteConnectionStatus] = {}
|
|
1479
|
+
known_ids = {connection.id for connection in settings.remote_connections}
|
|
1480
|
+
for stale_id in set(self._remote_connection_statuses) - known_ids:
|
|
1481
|
+
self._remote_connection_statuses.pop(stale_id, None)
|
|
1482
|
+
for connection in settings.remote_connections:
|
|
1483
|
+
status = self._remote_connection_statuses.get(connection.id)
|
|
1484
|
+
if status is None:
|
|
1485
|
+
status = CodexRemoteConnectionStatus(
|
|
1486
|
+
status="disconnected",
|
|
1487
|
+
detail="Not connected yet",
|
|
1488
|
+
)
|
|
1489
|
+
statuses[connection.id] = status
|
|
1490
|
+
return statuses
|
|
1491
|
+
|
|
1492
|
+
def _set_remote_connection_status(
|
|
1493
|
+
self,
|
|
1494
|
+
connection_id: str,
|
|
1495
|
+
status: str,
|
|
1496
|
+
detail: str = "",
|
|
1497
|
+
) -> None:
|
|
1498
|
+
self._remote_connection_statuses[connection_id] = CodexRemoteConnectionStatus(
|
|
1499
|
+
status=status, # type: ignore[arg-type]
|
|
1500
|
+
detail=detail,
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
def _ssh_base_args(
|
|
1504
|
+
self,
|
|
1505
|
+
connection: CodexRemoteConnection,
|
|
1506
|
+
*,
|
|
1507
|
+
use_tty: bool = False,
|
|
1508
|
+
verbose: bool = True,
|
|
1509
|
+
) -> tuple[str, ...]:
|
|
1510
|
+
args = [
|
|
1511
|
+
"ssh",
|
|
1512
|
+
"-tt" if use_tty else "-T",
|
|
1513
|
+
]
|
|
1514
|
+
if verbose:
|
|
1515
|
+
args.append("-v")
|
|
1516
|
+
args.extend(
|
|
1517
|
+
[
|
|
1518
|
+
"-o",
|
|
1519
|
+
"BatchMode=yes",
|
|
1520
|
+
"-o",
|
|
1521
|
+
"ServerAliveInterval=15",
|
|
1522
|
+
"-o",
|
|
1523
|
+
"ServerAliveCountMax=12",
|
|
1524
|
+
]
|
|
1525
|
+
)
|
|
1526
|
+
if connection.ssh_alias:
|
|
1527
|
+
args.append(connection.ssh_alias)
|
|
1528
|
+
return tuple(args)
|
|
1529
|
+
if connection.identity_file:
|
|
1530
|
+
args.extend(["-i", connection.identity_file])
|
|
1531
|
+
if connection.ssh_port is not None:
|
|
1532
|
+
args.extend(["-p", str(connection.ssh_port)])
|
|
1533
|
+
args.append(self._ssh_target(connection))
|
|
1534
|
+
return tuple(args)
|
|
1535
|
+
|
|
1536
|
+
def _ssh_target(self, connection: CodexRemoteConnection) -> str:
|
|
1537
|
+
if not connection.ssh_username:
|
|
1538
|
+
return connection.ssh_host
|
|
1539
|
+
return f"{connection.ssh_username}@{connection.ssh_host}"
|
|
1540
|
+
|
|
1541
|
+
def _remote_login_shell_command(self, script: str) -> str:
|
|
1542
|
+
path_prefix = (
|
|
1543
|
+
'PATH="${CODEX_INSTALL_DIR:-$HOME/.local/bin}:$PATH"; export PATH; '
|
|
1544
|
+
)
|
|
1545
|
+
return f'exec "${{SHELL:-sh}}" -l -i -c {shlex.quote(path_prefix + script)}'
|
|
1546
|
+
|
|
1547
|
+
def _thread_start_params(
|
|
1548
|
+
self,
|
|
1549
|
+
*,
|
|
1550
|
+
project_path: str | None,
|
|
1551
|
+
host_id: str,
|
|
1552
|
+
) -> JsonDict:
|
|
1553
|
+
settings = self.config_service.load_web_settings().codex
|
|
1554
|
+
remote_connection = self._connection_for_host(host_id, settings=settings)
|
|
1555
|
+
if remote_connection is not None:
|
|
1556
|
+
resolved_project_path = project_path or remote_connection.remote_path or "~"
|
|
1557
|
+
else:
|
|
1558
|
+
resolved_project_path = self.config_service.resolve_project_path(
|
|
1559
|
+
project_path
|
|
1560
|
+
)
|
|
1561
|
+
return {"cwd": resolved_project_path}
|
|
1562
|
+
|
|
1563
|
+
def _default_thread_cwd(
|
|
1564
|
+
self,
|
|
1565
|
+
settings: StoredCodexSettings,
|
|
1566
|
+
host_id: str,
|
|
1567
|
+
) -> str:
|
|
1568
|
+
remote_connection = self._connection_for_host(host_id, settings=settings)
|
|
1569
|
+
if remote_connection is not None:
|
|
1570
|
+
return remote_connection.remote_path or "~"
|
|
1571
|
+
return str(self.config_service.project_root)
|
|
1572
|
+
|
|
1573
|
+
def _default_thread_params(
|
|
1574
|
+
self,
|
|
1575
|
+
settings: StoredCodexSettings,
|
|
1576
|
+
codex_config: JsonDict,
|
|
1577
|
+
*,
|
|
1578
|
+
cwd: str,
|
|
1579
|
+
include_model: bool = True,
|
|
1580
|
+
) -> JsonDict:
|
|
1581
|
+
params: JsonDict = {
|
|
1582
|
+
"cwd": cwd,
|
|
1583
|
+
"approval_policy": self._approval_policy(settings, codex_config),
|
|
1584
|
+
"approvals_reviewer": self._approvals_reviewer(settings, codex_config),
|
|
1585
|
+
"sandbox": self._sandbox_mode(settings, codex_config),
|
|
1586
|
+
"service_tier": self._service_tier(settings, codex_config),
|
|
1587
|
+
"personality": self._personality(settings, codex_config),
|
|
1588
|
+
"base_instructions": _config_string(codex_config, "base_instructions"),
|
|
1589
|
+
"developer_instructions": _config_string(
|
|
1590
|
+
codex_config,
|
|
1591
|
+
"developer_instructions",
|
|
1592
|
+
),
|
|
1593
|
+
}
|
|
1594
|
+
if include_model:
|
|
1595
|
+
params["model"] = self._thread_model(settings, codex_config)
|
|
1596
|
+
params["model_provider"] = _config_string(codex_config, "model_provider")
|
|
1597
|
+
reasoning_effort = self._reasoning_effort(settings, codex_config)
|
|
1598
|
+
if reasoning_effort is not None:
|
|
1599
|
+
params["config"] = {"model_reasoning_effort": reasoning_effort}
|
|
1600
|
+
ephemeral = codex_config.get("ephemeral")
|
|
1601
|
+
if isinstance(ephemeral, bool):
|
|
1602
|
+
params["ephemeral"] = ephemeral
|
|
1603
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
1604
|
+
|
|
1605
|
+
def _thread_model(
|
|
1606
|
+
self,
|
|
1607
|
+
settings: StoredCodexSettings,
|
|
1608
|
+
codex_config: JsonDict,
|
|
1609
|
+
) -> str | None:
|
|
1610
|
+
return _config_string(codex_config, "model") or settings.model or None
|
|
1611
|
+
|
|
1612
|
+
def _approval_policy(
|
|
1613
|
+
self,
|
|
1614
|
+
settings: StoredCodexSettings,
|
|
1615
|
+
codex_config: JsonDict,
|
|
1616
|
+
) -> str | None:
|
|
1617
|
+
return (
|
|
1618
|
+
_config_string(codex_config, "approval_policy") or settings.approval_policy
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
def _approvals_reviewer(
|
|
1622
|
+
self,
|
|
1623
|
+
settings: StoredCodexSettings,
|
|
1624
|
+
codex_config: JsonDict,
|
|
1625
|
+
) -> str | None:
|
|
1626
|
+
return (
|
|
1627
|
+
_config_string(codex_config, "approvals_reviewer")
|
|
1628
|
+
or settings.approvals_reviewer
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
def _sandbox_mode(
|
|
1632
|
+
self,
|
|
1633
|
+
settings: StoredCodexSettings,
|
|
1634
|
+
codex_config: JsonDict,
|
|
1635
|
+
) -> str | None:
|
|
1636
|
+
return _config_string(codex_config, "sandbox_mode") or settings.sandbox
|
|
1637
|
+
|
|
1638
|
+
def _service_tier(
|
|
1639
|
+
self,
|
|
1640
|
+
settings: StoredCodexSettings,
|
|
1641
|
+
codex_config: JsonDict,
|
|
1642
|
+
) -> str | None:
|
|
1643
|
+
return (
|
|
1644
|
+
_config_string(codex_config, "service_tier")
|
|
1645
|
+
or settings.service_tier
|
|
1646
|
+
or None
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
def _personality(
|
|
1650
|
+
self,
|
|
1651
|
+
settings: StoredCodexSettings,
|
|
1652
|
+
codex_config: JsonDict,
|
|
1653
|
+
) -> str | None:
|
|
1654
|
+
value = _config_string(codex_config, "personality") or settings.personality
|
|
1655
|
+
return value if value != "none" else None
|
|
1656
|
+
|
|
1657
|
+
def _reasoning_effort(
|
|
1658
|
+
self,
|
|
1659
|
+
settings: StoredCodexSettings,
|
|
1660
|
+
codex_config: JsonDict,
|
|
1661
|
+
) -> str | None:
|
|
1662
|
+
value = _config_string(codex_config, "model_reasoning_effort")
|
|
1663
|
+
if value is None:
|
|
1664
|
+
value = settings.reasoning_effort.strip()
|
|
1665
|
+
return value if value and value != "none" else None
|
|
1666
|
+
|
|
1667
|
+
def _summaries_from_threads(
|
|
1668
|
+
self,
|
|
1669
|
+
response: ThreadListResponse,
|
|
1670
|
+
*,
|
|
1671
|
+
host_id: str,
|
|
1672
|
+
) -> list[CodexNativeSessionSummary]:
|
|
1673
|
+
summaries: list[CodexNativeSessionSummary] = []
|
|
1674
|
+
for thread in response.data:
|
|
1675
|
+
if thread.ephemeral:
|
|
1676
|
+
continue
|
|
1677
|
+
summary = _thread_summary(thread, host_id=host_id)
|
|
1678
|
+
self._thread_hosts[summary.thread_id] = host_id
|
|
1679
|
+
summaries.append(summary)
|
|
1680
|
+
return summaries
|
|
1681
|
+
|
|
1682
|
+
def _workspace_from_summaries(
|
|
1683
|
+
self,
|
|
1684
|
+
summaries: list[CodexNativeSessionSummary],
|
|
1685
|
+
*,
|
|
1686
|
+
settings: StoredCodexSettings,
|
|
1687
|
+
) -> CodexWorkspaceResponse:
|
|
1688
|
+
sessions_by_project: dict[str, list[CodexNativeSessionSummary]] = {
|
|
1689
|
+
project.id: [] for project in settings.projects
|
|
1690
|
+
}
|
|
1691
|
+
recent_threads: list[CodexNativeSessionSummary] = []
|
|
1692
|
+
projects_by_id = {project.id: project for project in settings.projects}
|
|
1693
|
+
projectless_thread_ids = set(settings.projectless_thread_ids)
|
|
1694
|
+
for summary in summaries:
|
|
1695
|
+
assignment = settings.thread_project_assignments.get(summary.thread_id)
|
|
1696
|
+
project = projects_by_id.get(assignment.project_id) if assignment else None
|
|
1697
|
+
if project is not None and project.host_id != summary.host_id:
|
|
1698
|
+
project = None
|
|
1699
|
+
if project is None and summary.thread_id in projectless_thread_ids:
|
|
1700
|
+
recent_threads.append(summary)
|
|
1701
|
+
continue
|
|
1702
|
+
if project is None:
|
|
1703
|
+
project = next(
|
|
1704
|
+
(
|
|
1705
|
+
candidate
|
|
1706
|
+
for candidate in settings.projects
|
|
1707
|
+
if candidate.host_id == summary.host_id
|
|
1708
|
+
and summary.project_path in candidate.root_paths
|
|
1709
|
+
),
|
|
1710
|
+
None,
|
|
1711
|
+
)
|
|
1712
|
+
if project is None:
|
|
1713
|
+
continue
|
|
1714
|
+
sessions_by_project[project.id].append(summary)
|
|
1715
|
+
|
|
1716
|
+
project_groups: list[CodexProjectGroup] = []
|
|
1717
|
+
for project in settings.projects:
|
|
1718
|
+
sessions = sessions_by_project[project.id]
|
|
1719
|
+
sessions.sort(
|
|
1720
|
+
key=lambda item: (
|
|
1721
|
+
_summary_used_at(item),
|
|
1722
|
+
item.started_at,
|
|
1723
|
+
item.thread_id,
|
|
1724
|
+
),
|
|
1725
|
+
reverse=True,
|
|
1726
|
+
)
|
|
1727
|
+
project_groups.append(
|
|
1728
|
+
CodexProjectGroup(
|
|
1729
|
+
id=project.id,
|
|
1730
|
+
project=project.name or "Untitled project",
|
|
1731
|
+
project_path=project.root_paths[0],
|
|
1732
|
+
host_id=project.host_id,
|
|
1733
|
+
kind=project.kind,
|
|
1734
|
+
root_paths=project.root_paths,
|
|
1735
|
+
session_count=len(sessions),
|
|
1736
|
+
sessions=sessions,
|
|
1737
|
+
)
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
project_groups.sort(
|
|
1741
|
+
key=lambda group: (
|
|
1742
|
+
-(_summary_used_at(group.sessions[0]) if group.sessions else 0.0),
|
|
1743
|
+
group.project.lower(),
|
|
1744
|
+
),
|
|
1745
|
+
)
|
|
1746
|
+
recent_threads.sort(
|
|
1747
|
+
key=lambda item: (
|
|
1748
|
+
_summary_used_at(item),
|
|
1749
|
+
item.started_at,
|
|
1750
|
+
item.thread_id,
|
|
1751
|
+
),
|
|
1752
|
+
reverse=True,
|
|
1753
|
+
)
|
|
1754
|
+
return CodexWorkspaceResponse(
|
|
1755
|
+
projects=project_groups,
|
|
1756
|
+
recent_threads=recent_threads,
|
|
1757
|
+
paired_editors=[],
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
def _notify(self, message: str) -> None:
|
|
1761
|
+
logger.info("open-codex-bridge: %s", message)
|