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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {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,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"]
|