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.
Files changed (40) hide show
  1. open_codex_ui-0.1.4.dist-info/METADATA +249 -0
  2. open_codex_ui-0.1.4.dist-info/RECORD +40 -0
  3. open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
  4. open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
  5. open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
  6. yier_web/__init__.py +6 -0
  7. yier_web/app.py +223 -0
  8. yier_web/auth.py +269 -0
  9. yier_web/cli.py +172 -0
  10. yier_web/codex/__init__.py +3 -0
  11. yier_web/codex/ipc_manager.py +1761 -0
  12. yier_web/codex/session_events.py +110 -0
  13. yier_web/codex/ws_commands.py +299 -0
  14. yier_web/config.py +651 -0
  15. yier_web/directory_picker.py +93 -0
  16. yier_web/event_stream.py +29 -0
  17. yier_web/frontend.py +204 -0
  18. yier_web/routes/__init__.py +17 -0
  19. yier_web/routes/codex.py +573 -0
  20. yier_web/routes/core.py +281 -0
  21. yier_web/schemas.py +534 -0
  22. yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
  23. yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
  24. yier_web/static/assets/LoginView-CELCom2O.js +101 -0
  25. yier_web/static/assets/api-CeihACIV.js +1099 -0
  26. yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
  27. yier_web/static/assets/index-CjVNk6ja.js +183 -0
  28. yier_web/static/assets/index-mSBvq1p8.js +79 -0
  29. yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
  30. yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
  31. yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
  32. yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
  33. yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
  34. yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
  35. yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
  36. yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
  37. yier_web/static/brand/open-codex-ui-logo.svg +85 -0
  38. yier_web/static/favicon.ico +0 -0
  39. yier_web/static/favicon.svg +99 -0
  40. 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)