codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,370 @@
1
+ import asyncio
2
+ import collections
3
+ import fcntl
4
+ import logging
5
+ import os
6
+ import select
7
+ import struct
8
+ import termios
9
+ import time
10
+ from typing import Dict, Optional
11
+
12
+ from ptyprocess import PtyProcess
13
+
14
+ logger = logging.getLogger("codex_autorunner.web.pty_session")
15
+
16
+ REPLAY_END = object()
17
+
18
+ ALT_SCREEN_ENTER_SEQS = (
19
+ b"\x1b[?1049h",
20
+ b"\x1b[?47h",
21
+ b"\x1b[?1047h",
22
+ )
23
+ ALT_SCREEN_EXIT_SEQS = (
24
+ b"\x1b[?1049l",
25
+ b"\x1b[?47l",
26
+ b"\x1b[?1047l",
27
+ )
28
+ ALT_SCREEN_SEQS = tuple((seq, True) for seq in ALT_SCREEN_ENTER_SEQS) + tuple(
29
+ (seq, False) for seq in ALT_SCREEN_EXIT_SEQS
30
+ )
31
+ ALT_SCREEN_MAX_LEN = max(len(seq) for seq, _state in ALT_SCREEN_SEQS)
32
+ PTY_WRITE_CHUNK_BYTES = 16 * 1024
33
+ # Cap per-flush work to keep the event loop responsive.
34
+ PTY_WRITE_FLUSH_MAX_BYTES = 256 * 1024
35
+ # Hard cap to prevent unbounded buffering when the PTY can't accept input.
36
+ PTY_PENDING_MAX_BYTES = 1024 * 1024
37
+
38
+
39
+ def default_env(env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
40
+ base = os.environ.copy()
41
+ if env:
42
+ base.update(env)
43
+ base.setdefault("TERM", "xterm-256color")
44
+ base.setdefault("COLORTERM", "truecolor")
45
+ return base
46
+
47
+
48
+ class PTYSession:
49
+ def __init__(self, cmd: list[str], cwd: str, env: Optional[Dict[str, str]] = None):
50
+ # echo=False to avoid double-printing user keystrokes
51
+ self.proc = PtyProcess.spawn(cmd, cwd=cwd, env=default_env(env), echo=False)
52
+ self.fd = self.proc.fd
53
+ self._set_nonblocking()
54
+ self.closed = False
55
+ self.last_active = time.time()
56
+
57
+ def _set_nonblocking(self) -> None:
58
+ """Ensure PTY IO doesn't block event loop."""
59
+ try:
60
+ flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
61
+ if not (flags & os.O_NONBLOCK):
62
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
63
+ except (OSError, IOError) as exc:
64
+ logger.debug("Failed to set PTY to non-blocking mode: %s", exc)
65
+
66
+ def resize(self, cols: int, rows: int) -> None:
67
+ if self.closed:
68
+ return
69
+ buf = struct.pack("HHHH", rows, cols, 0, 0)
70
+ fcntl.ioctl(self.fd, termios.TIOCSWINSZ, buf)
71
+ self.last_active = time.time()
72
+
73
+ def write(self, data: bytes) -> int:
74
+ """Best-effort non-blocking write; returns bytes written.
75
+
76
+ For user input, prefer ActiveSession.write_input so the loop never blocks.
77
+ """
78
+ if self.closed or not data:
79
+ return 0
80
+ try:
81
+ written = os.write(self.fd, data)
82
+ except (BlockingIOError, InterruptedError):
83
+ return 0
84
+ except OSError:
85
+ self.terminate()
86
+ return 0
87
+ if written:
88
+ self.last_active = time.time()
89
+ return written
90
+
91
+ def read(self, max_bytes: int = 4096) -> bytes:
92
+ if self.closed:
93
+ return b""
94
+ readable, _, _ = select.select([self.fd], [], [], 0)
95
+ if not readable:
96
+ return b""
97
+ try:
98
+ chunk = os.read(self.fd, max_bytes)
99
+ except BlockingIOError:
100
+ return b""
101
+ except OSError:
102
+ self.terminate()
103
+ return b""
104
+ if chunk:
105
+ self.last_active = time.time()
106
+ return chunk
107
+
108
+ def isalive(self) -> bool:
109
+ return not self.closed and self.proc.isalive()
110
+
111
+ def exit_code(self) -> Optional[int]:
112
+ return self.proc.exitstatus if not self.proc.isalive() else None
113
+
114
+ def is_stale(self, max_idle_seconds: int) -> bool:
115
+ return (time.time() - self.last_active) > max_idle_seconds
116
+
117
+ def terminate(self) -> None:
118
+ if self.closed:
119
+ return
120
+ try:
121
+ self.proc.terminate(force=True)
122
+ except (OSError, IOError) as exc:
123
+ logger.debug("Failed to terminate PTY process: %s", exc)
124
+ self.closed = True
125
+
126
+
127
+ class ActiveSession:
128
+ def __init__(
129
+ self, session_id: str, pty: PTYSession, loop: asyncio.AbstractEventLoop
130
+ ):
131
+ self.id = session_id
132
+ self.pty = pty
133
+ # Keep a bounded scrollback buffer for reconnects.
134
+ # This is sized in bytes (not chunks) so behavior is predictable.
135
+ self._buffer_max_bytes = 512 * 1024 # 512KB
136
+ self._buffer_bytes = 0
137
+ self.buffer: collections.deque[bytes] = collections.deque()
138
+ self.subscribers: set[asyncio.Queue[object]] = set()
139
+ self.lock = asyncio.Lock()
140
+ self.loop = loop
141
+ # Buffered input keeps the event loop from blocking on PTY writes.
142
+ self._pending_input = bytearray()
143
+ self._writer_active = False
144
+ # Track recently-seen input IDs (from web UI) to make "send" retries idempotent.
145
+ self._seen_input_ids_max = 256
146
+ self._seen_input_ids: collections.deque[str] = collections.deque()
147
+ self._seen_input_ids_set: set[str] = set()
148
+ now = time.time()
149
+ self.last_output_at = now
150
+ self.last_input_at = now
151
+ self._output_since_idle = False
152
+ self._idle_notified_at: Optional[float] = None
153
+ self._alt_screen_active = False
154
+ self._alt_screen_tail = b""
155
+ self._setup_reader()
156
+
157
+ def mark_input_id_seen(self, input_id: str) -> bool:
158
+ """Return True if this is the first time we've seen input_id."""
159
+ if input_id in self._seen_input_ids_set:
160
+ return False
161
+ self._seen_input_ids_set.add(input_id)
162
+ self._seen_input_ids.append(input_id)
163
+ while len(self._seen_input_ids) > self._seen_input_ids_max:
164
+ dropped = self._seen_input_ids.popleft()
165
+ self._seen_input_ids_set.discard(dropped)
166
+ return True
167
+
168
+ def _setup_reader(self):
169
+ self.loop.add_reader(self.pty.fd, self._read_callback)
170
+
171
+ def write_input(self, data: bytes) -> None:
172
+ """Queue terminal input and flush without blocking the event loop."""
173
+ if self.pty.closed or not data:
174
+ return
175
+ if len(self._pending_input) >= PTY_PENDING_MAX_BYTES:
176
+ return
177
+ remaining = PTY_PENDING_MAX_BYTES - len(self._pending_input)
178
+ if len(data) > remaining:
179
+ data = data[-remaining:]
180
+ self._pending_input.extend(data)
181
+ self._flush_pending_input()
182
+
183
+ def _enable_writer(self) -> None:
184
+ if self._writer_active:
185
+ return
186
+ try:
187
+ self.loop.add_writer(self.pty.fd, self._flush_pending_input)
188
+ self._writer_active = True
189
+ except (OSError, IOError) as exc:
190
+ logger.debug("Failed to enable PTY writer: %s", exc)
191
+ self._writer_active = False
192
+
193
+ def _disable_writer(self) -> None:
194
+ if not self._writer_active:
195
+ return
196
+ try:
197
+ self.loop.remove_writer(self.pty.fd)
198
+ except (OSError, IOError) as exc:
199
+ logger.debug("Failed to disable PTY writer: %s", exc)
200
+ self._writer_active = False
201
+
202
+ def _flush_pending_input(self) -> None:
203
+ """Drain queued input without blocking the event loop."""
204
+ if self.pty.closed:
205
+ self._pending_input.clear()
206
+ self._disable_writer()
207
+ return
208
+ if not self._pending_input:
209
+ self._disable_writer()
210
+ return
211
+ bytes_flushed = 0
212
+ while self._pending_input and bytes_flushed < PTY_WRITE_FLUSH_MAX_BYTES:
213
+ limit = min(len(self._pending_input), PTY_WRITE_CHUNK_BYTES)
214
+ chunk = bytes(self._pending_input[:limit])
215
+ try:
216
+ written = os.write(self.pty.fd, chunk)
217
+ except BlockingIOError:
218
+ self._enable_writer()
219
+ return
220
+ except InterruptedError:
221
+ continue
222
+ except OSError:
223
+ self.close()
224
+ return
225
+ if written <= 0:
226
+ break
227
+ del self._pending_input[:written]
228
+ bytes_flushed += written
229
+ self.pty.last_active = time.time()
230
+ if self._pending_input:
231
+ self._enable_writer()
232
+ else:
233
+ self._disable_writer()
234
+
235
+ def _read_callback(self):
236
+ try:
237
+ if self.pty.closed:
238
+ return
239
+ try:
240
+ data = os.read(self.pty.fd, 4096)
241
+ except BlockingIOError:
242
+ return
243
+ if data:
244
+ self._update_alt_screen_state(data)
245
+ now = time.time()
246
+ self.pty.last_active = now
247
+ self.last_output_at = now
248
+ self._output_since_idle = True
249
+ self._idle_notified_at = None
250
+ self.buffer.append(data)
251
+ self._buffer_bytes += len(data)
252
+ while self._buffer_bytes > self._buffer_max_bytes and self.buffer:
253
+ dropped = self.buffer.popleft()
254
+ self._buffer_bytes -= len(dropped)
255
+ for queue in list(self.subscribers):
256
+ try:
257
+ queue.put_nowait(data)
258
+ except asyncio.QueueFull:
259
+ logger.debug(
260
+ "Subscriber queue full, dropping data for session %s",
261
+ self.id,
262
+ )
263
+ else:
264
+ self.close()
265
+ except OSError:
266
+ self.close()
267
+
268
+ def add_subscriber(
269
+ self, *, include_replay_end: bool = True
270
+ ) -> asyncio.Queue[object]:
271
+ q: asyncio.Queue[object] = asyncio.Queue()
272
+ for chunk in self.buffer:
273
+ q.put_nowait(chunk)
274
+ if include_replay_end:
275
+ q.put_nowait(REPLAY_END)
276
+ self.subscribers.add(q)
277
+ return q
278
+
279
+ def refresh_alt_screen_state(self) -> None:
280
+ state = self._alt_screen_active
281
+ tail = b""
282
+ for chunk in self.buffer:
283
+ state, tail = self._scan_alt_screen_chunk(chunk, state, tail)
284
+ self._alt_screen_active = state
285
+ self._alt_screen_tail = tail
286
+
287
+ @property
288
+ def alt_screen_active(self) -> bool:
289
+ return self._alt_screen_active
290
+
291
+ def get_buffer_stats(self) -> tuple[int, int]:
292
+ return self._buffer_bytes, len(self.buffer)
293
+
294
+ def _scan_alt_screen_chunk(
295
+ self, data: bytes, state: bool, tail: bytes
296
+ ) -> tuple[bool, bytes]:
297
+ if not data:
298
+ return state, tail
299
+ haystack = tail + data
300
+ last_pos = -1
301
+ last_state: Optional[bool] = None
302
+ for seq, next_state in ALT_SCREEN_SEQS:
303
+ pos = haystack.rfind(seq)
304
+ if pos > last_pos:
305
+ last_pos = pos
306
+ last_state = next_state
307
+ if last_state is not None:
308
+ state = last_state
309
+ if ALT_SCREEN_MAX_LEN > 1:
310
+ tail = haystack[-(ALT_SCREEN_MAX_LEN - 1) :]
311
+ else:
312
+ tail = b""
313
+ return state, tail
314
+
315
+ def _update_alt_screen_state(self, data: bytes) -> None:
316
+ self._alt_screen_active, self._alt_screen_tail = self._scan_alt_screen_chunk(
317
+ data, self._alt_screen_active, self._alt_screen_tail
318
+ )
319
+
320
+ def remove_subscriber(self, q: asyncio.Queue[object]):
321
+ self.subscribers.discard(q)
322
+
323
+ def close(self):
324
+ try:
325
+ self._disable_writer()
326
+ except (OSError, IOError) as exc:
327
+ logger.debug("Failed to disable writer during close: %s", exc)
328
+ self._pending_input.clear()
329
+ if not self.pty.closed:
330
+ try:
331
+ self.loop.remove_reader(self.pty.fd)
332
+ except (OSError, IOError) as exc:
333
+ logger.debug("Failed to remove reader during close: %s", exc)
334
+ try:
335
+ self.pty.terminate()
336
+ except (OSError, IOError) as exc:
337
+ logger.debug("Failed to terminate PTY during close: %s", exc)
338
+ for queue in list(self.subscribers):
339
+ try:
340
+ queue.put_nowait(None)
341
+ except asyncio.QueueFull:
342
+ pass
343
+ self.subscribers.clear()
344
+
345
+ def mark_input_activity(self) -> None:
346
+ now = time.time()
347
+ self.last_input_at = now
348
+ self._output_since_idle = False
349
+ self._idle_notified_at = None
350
+
351
+ def should_notify_idle(self, idle_seconds: float) -> bool:
352
+ if idle_seconds <= 0:
353
+ return False
354
+ if not self._output_since_idle:
355
+ return False
356
+ if self._idle_notified_at is not None:
357
+ return False
358
+ if time.time() - self.last_output_at < idle_seconds:
359
+ return False
360
+ self._idle_notified_at = time.time()
361
+ self._output_since_idle = False
362
+ return True
363
+
364
+ async def wait_closed(self, timeout: float = 5.0):
365
+ """Wait for the underlying PTY process to terminate."""
366
+ start = time.time()
367
+ while time.time() - start < timeout:
368
+ if not self.pty.isalive():
369
+ return
370
+ await asyncio.sleep(0.1)
@@ -0,0 +1,6 @@
1
+ """Backward-compatible review service wrapper.
2
+
3
+ Review implementation lives under flows.review.service.
4
+ """
5
+
6
+ from ...flows.review.service import * # noqa: F401,F403
@@ -0,0 +1,82 @@
1
+ """
2
+ Modular API routes for the codex-autorunner server.
3
+
4
+ This package splits monolithic api_routes.py into focused modules:
5
+ - base: Index, WebSocket terminal, and general endpoints
6
+ - agents: Agent harness models and event streaming
7
+ - app_server: App-server thread registry endpoints
8
+ - workspace: Optional workspace docs (active_context/decisions/spec)
9
+ - flows: Flow runtime management (start/stop/resume/status/events/artifacts)
10
+ - messages: Inbox/message wrappers over ticket_flow dispatch + reply histories
11
+ - repos: Run control (start/stop/resume/reset)
12
+ - sessions: Terminal session registry endpoints
13
+ - settings: Session settings for autorunner overrides
14
+ - file_chat: Unified file chat (tickets + workspace docs)
15
+ - voice: Voice transcription and config
16
+ - terminal_images: Terminal image uploads
17
+ """
18
+
19
+ from pathlib import Path
20
+
21
+ from fastapi import APIRouter
22
+
23
+ from .agents import build_agents_routes
24
+ from .analytics import build_analytics_routes
25
+ from .app_server import build_app_server_routes
26
+ from .archive import build_archive_routes
27
+ from .base import build_base_routes, build_frontend_routes
28
+ from .file_chat import build_file_chat_routes
29
+ from .filebox import build_filebox_routes
30
+ from .flows import build_flow_routes
31
+ from .messages import build_messages_routes
32
+ from .repos import build_repos_routes
33
+ from .review import build_review_routes
34
+ from .sessions import build_sessions_routes
35
+ from .settings import build_settings_routes
36
+ from .system import build_system_routes
37
+ from .templates import build_templates_routes
38
+ from .terminal_images import build_terminal_image_routes
39
+ from .usage import build_usage_routes
40
+ from .voice import build_voice_routes
41
+ from .workspace import build_workspace_routes
42
+
43
+
44
+ def build_repo_router(static_dir: Path) -> APIRouter:
45
+ """
46
+ Build complete API router by combining all route modules.
47
+
48
+ Args:
49
+ static_dir: Path to static assets directory
50
+
51
+ Returns:
52
+ Combined APIRouter with all endpoints
53
+ """
54
+ router = APIRouter()
55
+
56
+ # Include all route modules
57
+ router.include_router(build_base_routes(static_dir))
58
+ router.include_router(build_analytics_routes())
59
+ router.include_router(build_archive_routes())
60
+ router.include_router(build_agents_routes())
61
+ router.include_router(build_app_server_routes())
62
+ router.include_router(build_workspace_routes())
63
+ router.include_router(build_flow_routes())
64
+ router.include_router(build_filebox_routes())
65
+ router.include_router(build_file_chat_routes())
66
+ router.include_router(build_messages_routes())
67
+ router.include_router(build_repos_routes())
68
+ router.include_router(build_review_routes())
69
+ router.include_router(build_sessions_routes())
70
+ router.include_router(build_settings_routes())
71
+ router.include_router(build_system_routes())
72
+ router.include_router(build_templates_routes())
73
+ router.include_router(build_terminal_image_routes())
74
+ router.include_router(build_usage_routes())
75
+ router.include_router(build_voice_routes())
76
+ # Include frontend routes last to avoid shadowing API routes
77
+ router.include_router(build_frontend_routes(static_dir))
78
+
79
+ return router
80
+
81
+
82
+ __all__ = ["build_repo_router"]
@@ -0,0 +1,138 @@
1
+ """
2
+ Agent harness support routes (models + event streaming).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Optional
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from ....agents.codex.harness import CodexHarness
13
+ from ....agents.opencode.harness import OpenCodeHarness
14
+ from ....agents.opencode.supervisor import OpenCodeSupervisorError
15
+ from ....agents.types import ModelCatalog
16
+ from .shared import SSE_HEADERS
17
+
18
+
19
+ def _available_agents(request: Request) -> tuple[list[dict[str, str]], str]:
20
+ agents: list[dict[str, str]] = []
21
+ default_agent: Optional[str] = None
22
+
23
+ if getattr(request.app.state, "app_server_supervisor", None) is not None:
24
+ agents.append({"id": "codex", "name": "Codex", "protocol_version": "2.0"})
25
+ default_agent = "codex"
26
+
27
+ if getattr(request.app.state, "opencode_supervisor", None) is not None:
28
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
29
+ version = None
30
+ if supervisor and hasattr(supervisor, "_handles"):
31
+ handles = supervisor._handles
32
+ if handles:
33
+ first_handle = next(iter(handles.values()), None)
34
+ if first_handle:
35
+ version = getattr(first_handle, "version", None)
36
+ agent_data = {"id": "opencode", "name": "OpenCode"}
37
+ if version:
38
+ agent_data["version"] = str(version)
39
+ agents.append(agent_data)
40
+ if default_agent is None:
41
+ default_agent = "opencode"
42
+
43
+ if not agents:
44
+ agents = [{"id": "codex", "name": "Codex", "protocol_version": "2.0"}]
45
+ default_agent = "codex"
46
+
47
+ return agents, default_agent or "codex"
48
+
49
+
50
+ def _serialize_model_catalog(catalog: ModelCatalog) -> dict[str, Any]:
51
+ return {
52
+ "default_model": catalog.default_model,
53
+ "models": [
54
+ {
55
+ "id": model.id,
56
+ "display_name": model.display_name,
57
+ "supports_reasoning": model.supports_reasoning,
58
+ "reasoning_options": list(model.reasoning_options),
59
+ }
60
+ for model in catalog.models
61
+ ],
62
+ }
63
+
64
+
65
+ def build_agents_routes() -> APIRouter:
66
+ router = APIRouter()
67
+
68
+ @router.get("/api/agents")
69
+ def list_agents(request: Request) -> dict[str, Any]:
70
+ agents, default_agent = _available_agents(request)
71
+ return {"agents": agents, "default": default_agent}
72
+
73
+ @router.get("/api/agents/{agent}/models")
74
+ async def list_agent_models(agent: str, request: Request):
75
+ agent_id = (agent or "").strip().lower()
76
+ engine = request.app.state.engine
77
+ if agent_id == "codex":
78
+ supervisor = request.app.state.app_server_supervisor
79
+ events = request.app.state.app_server_events
80
+ if supervisor is None:
81
+ raise HTTPException(status_code=404, detail="Codex harness unavailable")
82
+ codex_harness = CodexHarness(supervisor, events)
83
+ catalog = await codex_harness.model_catalog(engine.repo_root)
84
+ return _serialize_model_catalog(catalog)
85
+ if agent_id == "opencode":
86
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
87
+ if supervisor is None:
88
+ raise HTTPException(
89
+ status_code=404, detail="OpenCode harness unavailable"
90
+ )
91
+ try:
92
+ opencode_harness = OpenCodeHarness(supervisor)
93
+ catalog = await opencode_harness.model_catalog(engine.repo_root)
94
+ return _serialize_model_catalog(catalog)
95
+ except OpenCodeSupervisorError as exc:
96
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
97
+ except Exception as exc:
98
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
99
+ raise HTTPException(status_code=404, detail="Unknown agent")
100
+
101
+ @router.get("/api/agents/{agent}/turns/{turn_id}/events")
102
+ async def stream_agent_turn_events(
103
+ agent: str, turn_id: str, request: Request, thread_id: Optional[str] = None
104
+ ):
105
+ agent_id = (agent or "").strip().lower()
106
+ if agent_id == "codex":
107
+ events = getattr(request.app.state, "app_server_events", None)
108
+ if events is None:
109
+ raise HTTPException(status_code=404, detail="Codex events unavailable")
110
+ if not thread_id:
111
+ raise HTTPException(status_code=400, detail="thread_id is required")
112
+ return StreamingResponse(
113
+ events.stream(thread_id, turn_id),
114
+ media_type="text/event-stream",
115
+ headers=SSE_HEADERS,
116
+ )
117
+ if agent_id == "opencode":
118
+ if not thread_id:
119
+ raise HTTPException(status_code=400, detail="thread_id is required")
120
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
121
+ if supervisor is None:
122
+ raise HTTPException(
123
+ status_code=404, detail="OpenCode events unavailable"
124
+ )
125
+ harness = OpenCodeHarness(supervisor)
126
+ return StreamingResponse(
127
+ harness.stream_events(
128
+ request.app.state.engine.repo_root, thread_id, turn_id
129
+ ),
130
+ media_type="text/event-stream",
131
+ headers=SSE_HEADERS,
132
+ )
133
+ raise HTTPException(status_code=404, detail="Unknown agent")
134
+
135
+ return router
136
+
137
+
138
+ __all__ = ["build_agents_routes"]