yee88 0.3.0__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 (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/runners/codex.py ADDED
@@ -0,0 +1,656 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import msgspec
9
+
10
+ from ..backends import EngineBackend, EngineConfig
11
+ from ..config import ConfigError
12
+ from ..events import EventFactory
13
+ from ..logging import get_logger
14
+ from ..model import ActionPhase, EngineId, ResumeToken, TakopiEvent
15
+ from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
16
+ from .run_options import get_run_options
17
+ from ..schemas import codex as codex_schema
18
+ from ..utils.paths import relativize_command
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ ENGINE: EngineId = "codex"
23
+
24
+ __all__ = [
25
+ "ENGINE",
26
+ "CodexRunner",
27
+ "find_exec_only_flag",
28
+ "translate_codex_event",
29
+ ]
30
+
31
+ _RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
32
+ _RECONNECTING_RE = re.compile(
33
+ r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
34
+ re.IGNORECASE,
35
+ )
36
+ _EXEC_ONLY_FLAGS = {
37
+ "--skip-git-repo-check",
38
+ "--json",
39
+ "--output-schema",
40
+ "--output-last-message",
41
+ "--color",
42
+ "-o",
43
+ }
44
+ _EXEC_ONLY_PREFIXES = (
45
+ "--output-schema=",
46
+ "--output-last-message=",
47
+ "--color=",
48
+ )
49
+
50
+
51
+ def find_exec_only_flag(extra_args: list[str]) -> str | None:
52
+ for arg in extra_args:
53
+ if arg in _EXEC_ONLY_FLAGS:
54
+ return arg
55
+ for prefix in _EXEC_ONLY_PREFIXES:
56
+ if arg.startswith(prefix):
57
+ return arg
58
+ return None
59
+
60
+
61
+ def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
62
+ match = _RECONNECTING_RE.match(message)
63
+ if not match:
64
+ return None
65
+ try:
66
+ attempt = int(match.group("attempt"))
67
+ max_attempts = int(match.group("max"))
68
+ except (TypeError, ValueError):
69
+ return None
70
+ return (attempt, max_attempts)
71
+
72
+
73
+ def _short_tool_name(server: str | None, tool: str | None) -> str:
74
+ name = ".".join(part for part in (server, tool) if part)
75
+ return name or "tool"
76
+
77
+
78
+ def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
79
+ if isinstance(result, codex_schema.McpToolCallItemResult):
80
+ summary: dict[str, Any] = {}
81
+ content = result.content
82
+ if isinstance(content, list):
83
+ summary["content_blocks"] = len(content)
84
+ elif content is not None:
85
+ summary["content_blocks"] = 1
86
+ summary["has_structured"] = result.structured_content is not None
87
+ return summary or None
88
+
89
+ if isinstance(result, dict):
90
+ summary = {}
91
+ content = result.get("content")
92
+ if isinstance(content, list):
93
+ summary["content_blocks"] = len(content)
94
+ elif content is not None:
95
+ summary["content_blocks"] = 1
96
+
97
+ structured_key: str | None = None
98
+ if "structured_content" in result:
99
+ structured_key = "structured_content"
100
+ elif "structured" in result:
101
+ structured_key = "structured"
102
+
103
+ if structured_key is not None:
104
+ summary["has_structured"] = result.get(structured_key) is not None
105
+ return summary or None
106
+
107
+ return None
108
+
109
+
110
+ def _normalize_change_list(changes: list[Any]) -> list[dict[str, str]]:
111
+ normalized: list[dict[str, str]] = []
112
+ for change in changes:
113
+ path: str | None = None
114
+ kind: str | None = None
115
+ if isinstance(change, codex_schema.FileUpdateChange):
116
+ path = change.path
117
+ kind = change.kind
118
+ elif isinstance(change, dict):
119
+ path = change.get("path")
120
+ kind = change.get("kind")
121
+ if not isinstance(path, str) or not path:
122
+ continue
123
+ entry = {"path": path}
124
+ if isinstance(kind, str) and kind:
125
+ entry["kind"] = kind
126
+ normalized.append(entry)
127
+ return normalized
128
+
129
+
130
+ def _format_change_summary(changes: list[Any]) -> str:
131
+ paths: list[str] = []
132
+ for change in changes:
133
+ if isinstance(change, codex_schema.FileUpdateChange):
134
+ if change.path:
135
+ paths.append(change.path)
136
+ continue
137
+ if isinstance(change, dict):
138
+ path = change.get("path")
139
+ if isinstance(path, str) and path:
140
+ paths.append(path)
141
+ if not paths:
142
+ total = len(changes)
143
+ if total <= 0:
144
+ return "files"
145
+ return f"{total} files"
146
+ return ", ".join(str(path) for path in paths)
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class _TodoSummary:
151
+ done: int
152
+ total: int
153
+ next_text: str | None
154
+
155
+
156
+ def _summarize_todo_list(items: Any) -> _TodoSummary:
157
+ if not isinstance(items, list):
158
+ return _TodoSummary(done=0, total=0, next_text=None)
159
+
160
+ done = 0
161
+ total = 0
162
+ next_text: str | None = None
163
+
164
+ for raw_item in items:
165
+ if isinstance(raw_item, codex_schema.TodoItem):
166
+ total += 1
167
+ if raw_item.completed:
168
+ done += 1
169
+ continue
170
+ if next_text is None:
171
+ next_text = raw_item.text
172
+ continue
173
+ if not isinstance(raw_item, dict):
174
+ continue
175
+ total += 1
176
+ completed = raw_item.get("completed") is True
177
+ if completed:
178
+ done += 1
179
+ continue
180
+ if next_text is None:
181
+ text = raw_item.get("text")
182
+ next_text = str(text) if text is not None else None
183
+
184
+ return _TodoSummary(done=done, total=total, next_text=next_text)
185
+
186
+
187
+ def _todo_title(summary: _TodoSummary) -> str:
188
+ if summary.total <= 0:
189
+ return "todo"
190
+ if summary.next_text:
191
+ return f"todo {summary.done}/{summary.total}: {summary.next_text}"
192
+ return f"todo {summary.done}/{summary.total}: done"
193
+
194
+
195
+ def _translate_item_event(
196
+ phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory
197
+ ) -> list[TakopiEvent]:
198
+ match item:
199
+ case codex_schema.AgentMessageItem():
200
+ return []
201
+ case codex_schema.ErrorItem(id=action_id, message=message):
202
+ if phase != "completed":
203
+ return []
204
+ return [
205
+ factory.action_completed(
206
+ action_id=action_id,
207
+ kind="warning",
208
+ title=message,
209
+ detail={"message": message},
210
+ ok=False,
211
+ message=message,
212
+ level="warning",
213
+ ),
214
+ ]
215
+ case codex_schema.CommandExecutionItem(
216
+ id=action_id,
217
+ command=command,
218
+ exit_code=exit_code,
219
+ status=status,
220
+ ):
221
+ title = relativize_command(command)
222
+ if phase in {"started", "updated"}:
223
+ return [
224
+ factory.action(
225
+ phase=phase,
226
+ action_id=action_id,
227
+ kind="command",
228
+ title=title,
229
+ )
230
+ ]
231
+ if phase == "completed":
232
+ ok = status == "completed"
233
+ if isinstance(exit_code, int):
234
+ ok = ok and exit_code == 0
235
+ detail = {"exit_code": exit_code, "status": status}
236
+ return [
237
+ factory.action_completed(
238
+ action_id=action_id,
239
+ kind="command",
240
+ title=title,
241
+ detail=detail,
242
+ ok=ok,
243
+ ),
244
+ ]
245
+ case codex_schema.McpToolCallItem(
246
+ id=action_id,
247
+ server=server,
248
+ tool=tool,
249
+ arguments=arguments,
250
+ status=status,
251
+ result=result,
252
+ error=error,
253
+ ):
254
+ title = _short_tool_name(server, tool)
255
+ detail: dict[str, Any] = {
256
+ "server": server,
257
+ "tool": tool,
258
+ "status": status,
259
+ "arguments": arguments,
260
+ }
261
+
262
+ if phase in {"started", "updated"}:
263
+ return [
264
+ factory.action(
265
+ phase=phase,
266
+ action_id=action_id,
267
+ kind="tool",
268
+ title=title,
269
+ detail=detail,
270
+ )
271
+ ]
272
+ if phase == "completed":
273
+ ok = status == "completed" and error is None
274
+ if error is not None:
275
+ detail["error_message"] = str(error.message)
276
+ result_summary = _summarize_tool_result(result)
277
+ if result_summary is not None:
278
+ detail["result_summary"] = result_summary
279
+ return [
280
+ factory.action_completed(
281
+ action_id=action_id,
282
+ kind="tool",
283
+ title=title,
284
+ detail=detail,
285
+ ok=ok,
286
+ ),
287
+ ]
288
+ case codex_schema.WebSearchItem(id=action_id, query=query):
289
+ detail = {"query": query}
290
+ if phase in {"started", "updated"}:
291
+ return [
292
+ factory.action(
293
+ phase=phase,
294
+ action_id=action_id,
295
+ kind="web_search",
296
+ title=query,
297
+ detail=detail,
298
+ )
299
+ ]
300
+ if phase == "completed":
301
+ return [
302
+ factory.action_completed(
303
+ action_id=action_id,
304
+ kind="web_search",
305
+ title=query,
306
+ detail=detail,
307
+ ok=True,
308
+ )
309
+ ]
310
+ case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status):
311
+ if phase != "completed":
312
+ return []
313
+ title = _format_change_summary(changes)
314
+ normalized_changes = _normalize_change_list(changes)
315
+ detail = {
316
+ "changes": normalized_changes,
317
+ "status": status,
318
+ "error": None,
319
+ }
320
+ ok = status == "completed"
321
+ return [
322
+ factory.action_completed(
323
+ action_id=action_id,
324
+ kind="file_change",
325
+ title=title,
326
+ detail=detail,
327
+ ok=ok,
328
+ )
329
+ ]
330
+ case codex_schema.TodoListItem(id=action_id, items=items):
331
+ summary = _summarize_todo_list(items)
332
+ title = _todo_title(summary)
333
+ detail = {"done": summary.done, "total": summary.total}
334
+ if phase in {"started", "updated"}:
335
+ return [
336
+ factory.action(
337
+ phase=phase,
338
+ action_id=action_id,
339
+ kind="note",
340
+ title=title,
341
+ detail=detail,
342
+ )
343
+ ]
344
+ if phase == "completed":
345
+ return [
346
+ factory.action_completed(
347
+ action_id=action_id,
348
+ kind="note",
349
+ title=title,
350
+ detail=detail,
351
+ ok=True,
352
+ )
353
+ ]
354
+ case codex_schema.ReasoningItem(id=action_id, text=text):
355
+ if phase in {"started", "updated"}:
356
+ return [
357
+ factory.action(
358
+ phase=phase,
359
+ action_id=action_id,
360
+ kind="note",
361
+ title=text,
362
+ )
363
+ ]
364
+ if phase == "completed":
365
+ return [
366
+ factory.action_completed(
367
+ action_id=action_id,
368
+ kind="note",
369
+ title=text,
370
+ ok=True,
371
+ )
372
+ ]
373
+ return []
374
+
375
+
376
+ def translate_codex_event(
377
+ event: codex_schema.ThreadEvent,
378
+ *,
379
+ title: str,
380
+ factory: EventFactory,
381
+ ) -> list[TakopiEvent]:
382
+ match event:
383
+ case codex_schema.ThreadStarted(thread_id=thread_id):
384
+ token = ResumeToken(engine=ENGINE, value=thread_id)
385
+ return [factory.started(token, title=title)]
386
+ case codex_schema.ItemStarted(item=item):
387
+ return _translate_item_event("started", item, factory=factory)
388
+ case codex_schema.ItemUpdated(item=item):
389
+ return _translate_item_event("updated", item, factory=factory)
390
+ case codex_schema.ItemCompleted(item=item):
391
+ return _translate_item_event("completed", item, factory=factory)
392
+ case _:
393
+ return []
394
+
395
+
396
+ @dataclass(slots=True)
397
+ class CodexRunState:
398
+ factory: EventFactory
399
+ note_seq: int = 0
400
+ final_answer: str | None = None
401
+ turn_index: int = 0
402
+
403
+
404
+ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
405
+ engine: EngineId = ENGINE
406
+ resume_re = _RESUME_RE
407
+ logger = logger
408
+
409
+ def __init__(
410
+ self,
411
+ *,
412
+ codex_cmd: str,
413
+ extra_args: list[str],
414
+ title: str = "Codex",
415
+ ) -> None:
416
+ self.codex_cmd = codex_cmd
417
+ self.extra_args = extra_args
418
+ self.session_title = title
419
+
420
+ def command(self) -> str:
421
+ return self.codex_cmd
422
+
423
+ def build_args(
424
+ self,
425
+ prompt: str,
426
+ resume: ResumeToken | None,
427
+ *,
428
+ state: Any,
429
+ ) -> list[str]:
430
+ run_options = get_run_options()
431
+ args = [*self.extra_args]
432
+ if run_options is not None:
433
+ if run_options.model:
434
+ args.extend(["--model", str(run_options.model)])
435
+ if run_options.reasoning:
436
+ args.extend(
437
+ [
438
+ "-c",
439
+ f"model_reasoning_effort={run_options.reasoning}",
440
+ ]
441
+ )
442
+ args.extend(
443
+ [
444
+ "exec",
445
+ "--json",
446
+ "--skip-git-repo-check",
447
+ "--color=never",
448
+ ]
449
+ )
450
+ if resume:
451
+ args.extend(["resume", resume.value, "-"])
452
+ else:
453
+ args.append("-")
454
+ return args
455
+
456
+ def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState:
457
+ return CodexRunState(factory=EventFactory(ENGINE))
458
+
459
+ def start_run(
460
+ self,
461
+ prompt: str,
462
+ resume: ResumeToken | None,
463
+ *,
464
+ state: CodexRunState,
465
+ ) -> None:
466
+ pass
467
+
468
+ def decode_jsonl(self, *, line: bytes) -> codex_schema.ThreadEvent:
469
+ return codex_schema.decode_event(line)
470
+
471
+ def decode_error_events(
472
+ self,
473
+ *,
474
+ raw: str,
475
+ line: str,
476
+ error: Exception,
477
+ state: CodexRunState,
478
+ ) -> list[TakopiEvent]:
479
+ if isinstance(error, msgspec.DecodeError):
480
+ self.get_logger().warning(
481
+ "jsonl.msgspec.invalid",
482
+ tag=self.tag(),
483
+ error=str(error),
484
+ error_type=error.__class__.__name__,
485
+ )
486
+ return []
487
+ return super().decode_error_events(
488
+ raw=raw,
489
+ line=line,
490
+ error=error,
491
+ state=state,
492
+ )
493
+
494
+ def pipes_error_message(self) -> str:
495
+ return "codex exec failed to open subprocess pipes"
496
+
497
+ def translate(
498
+ self,
499
+ data: codex_schema.ThreadEvent,
500
+ *,
501
+ state: CodexRunState,
502
+ resume: ResumeToken | None,
503
+ found_session: ResumeToken | None,
504
+ ) -> list[TakopiEvent]:
505
+ factory = state.factory
506
+ match data:
507
+ case codex_schema.StreamError(message=message):
508
+ reconnect = _parse_reconnect_message(message)
509
+ if reconnect is not None:
510
+ attempt, max_attempts = reconnect
511
+ phase: ActionPhase = "started" if attempt <= 1 else "updated"
512
+ return [
513
+ factory.action(
514
+ phase=phase,
515
+ action_id="codex.reconnect",
516
+ kind="note",
517
+ title=message,
518
+ detail={"attempt": attempt, "max": max_attempts},
519
+ level="info",
520
+ )
521
+ ]
522
+ return [self.note_event(message, state=state, ok=False)]
523
+ case codex_schema.TurnFailed(error=error):
524
+ resume_for_completed = found_session or resume
525
+ return [
526
+ factory.completed_error(
527
+ error=error.message,
528
+ answer=state.final_answer or "",
529
+ resume=resume_for_completed,
530
+ )
531
+ ]
532
+ case codex_schema.TurnStarted():
533
+ action_id = f"turn_{state.turn_index}"
534
+ state.turn_index += 1
535
+ return [
536
+ factory.action_started(
537
+ action_id=action_id,
538
+ kind="turn",
539
+ title="turn started",
540
+ )
541
+ ]
542
+ case codex_schema.TurnCompleted(usage=usage):
543
+ resume_for_completed = found_session or resume
544
+ return [
545
+ factory.completed_ok(
546
+ answer=state.final_answer or "",
547
+ resume=resume_for_completed,
548
+ usage=msgspec.to_builtins(usage),
549
+ )
550
+ ]
551
+ case codex_schema.ItemCompleted(
552
+ item=codex_schema.AgentMessageItem(text=text)
553
+ ):
554
+ if state.final_answer is None:
555
+ state.final_answer = text
556
+ else:
557
+ logger.debug("codex.multiple_agent_messages")
558
+ state.final_answer = text
559
+ case _:
560
+ pass
561
+
562
+ return translate_codex_event(
563
+ data,
564
+ title=self.session_title,
565
+ factory=factory,
566
+ )
567
+
568
+ def process_error_events(
569
+ self,
570
+ rc: int,
571
+ *,
572
+ resume: ResumeToken | None,
573
+ found_session: ResumeToken | None,
574
+ state: CodexRunState,
575
+ ) -> list[TakopiEvent]:
576
+ message = f"codex exec failed (rc={rc})."
577
+ resume_for_completed = found_session or resume
578
+ return [
579
+ self.note_event(
580
+ message,
581
+ state=state,
582
+ ok=False,
583
+ ),
584
+ state.factory.completed_error(
585
+ error=message,
586
+ answer=state.final_answer or "",
587
+ resume=resume_for_completed,
588
+ ),
589
+ ]
590
+
591
+ def stream_end_events(
592
+ self,
593
+ *,
594
+ resume: ResumeToken | None,
595
+ found_session: ResumeToken | None,
596
+ state: CodexRunState,
597
+ ) -> list[TakopiEvent]:
598
+ if not found_session:
599
+ message = "codex exec finished but no session_id/thread_id was captured"
600
+ resume_for_completed = resume
601
+ return [
602
+ state.factory.completed_error(
603
+ error=message,
604
+ answer=state.final_answer or "",
605
+ resume=resume_for_completed,
606
+ )
607
+ ]
608
+ logger.info("codex.session.completed", resume=found_session.value)
609
+ return [
610
+ state.factory.completed_ok(
611
+ answer=state.final_answer or "",
612
+ resume=found_session,
613
+ )
614
+ ]
615
+
616
+
617
+ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
618
+ codex_cmd = "codex"
619
+
620
+ extra_args_value = config.get("extra_args")
621
+ if extra_args_value is None:
622
+ extra_args = ["-c", "notify=[]"]
623
+ elif isinstance(extra_args_value, list) and all(
624
+ isinstance(item, str) for item in extra_args_value
625
+ ):
626
+ extra_args = list(extra_args_value)
627
+ else:
628
+ raise ConfigError(
629
+ f"Invalid `codex.extra_args` in {config_path}; expected a list of strings."
630
+ )
631
+
632
+ exec_only_flag = find_exec_only_flag(extra_args)
633
+ if exec_only_flag:
634
+ raise ConfigError(
635
+ f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
636
+ f"{exec_only_flag!r} is managed by Takopi."
637
+ )
638
+
639
+ title = "Codex"
640
+ profile_value = config.get("profile")
641
+ if profile_value:
642
+ if not isinstance(profile_value, str):
643
+ raise ConfigError(
644
+ f"Invalid `codex.profile` in {config_path}; expected a string."
645
+ )
646
+ extra_args.extend(["--profile", profile_value])
647
+ title = profile_value
648
+
649
+ return CodexRunner(codex_cmd=codex_cmd, extra_args=extra_args, title=title)
650
+
651
+
652
+ BACKEND = EngineBackend(
653
+ id="codex",
654
+ build_runner=build_runner,
655
+ install_cmd="npm install -g @openai/codex",
656
+ )