codex-autorunner 0.1.2__py3-none-any.whl → 1.1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,836 @@
1
+ """
2
+ Unified file chat routes: AI-powered editing for tickets and workspace docs.
3
+
4
+ Targets:
5
+ - ticket:{index} -> .codex-autorunner/tickets/TICKET-###.md
6
+ - workspace:{path} -> .codex-autorunner/workspace/{path}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import difflib
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, AsyncIterator, Dict, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from fastapi.responses import StreamingResponse
21
+
22
+ from ....agents.registry import validate_agent_id
23
+ from ....core import drafts as draft_utils
24
+ from ....core.state import now_iso
25
+ from ....core.utils import atomic_write, find_repo_root
26
+ from ....integrations.app_server.event_buffer import format_sse
27
+ from ....workspace.paths import (
28
+ WORKSPACE_DOC_KINDS,
29
+ normalize_workspace_rel_path,
30
+ workspace_doc_path,
31
+ )
32
+ from .shared import SSE_HEADERS
33
+
34
+ FILE_CHAT_STATE_NAME = draft_utils.FILE_CHAT_STATE_NAME
35
+ FILE_CHAT_TIMEOUT_SECONDS = 180
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class FileChatError(Exception):
40
+ """Base error for file chat failures."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Target:
45
+ target: str
46
+ kind: str # "ticket" | "workspace"
47
+ id: str # "001" | "spec"
48
+ path: Path
49
+ rel_path: str
50
+ state_key: str
51
+
52
+
53
+ def _state_path(repo_root: Path) -> Path:
54
+ return draft_utils.state_path(repo_root)
55
+
56
+
57
+ def _load_state(repo_root: Path) -> Dict[str, Any]:
58
+ return draft_utils.load_state(repo_root)
59
+
60
+
61
+ def _save_state(repo_root: Path, state: Dict[str, Any]) -> None:
62
+ draft_utils.save_state(repo_root, state)
63
+
64
+
65
+ def _hash_content(content: str) -> str:
66
+ return draft_utils.hash_content(content)
67
+
68
+
69
+ def _resolve_repo_root(request: Optional[Request] = None) -> Path:
70
+ if request is not None:
71
+ engine = getattr(request.app.state, "engine", None)
72
+ repo_root = getattr(engine, "repo_root", None)
73
+ if isinstance(repo_root, Path):
74
+ return repo_root
75
+ if isinstance(repo_root, str):
76
+ try:
77
+ return Path(repo_root)
78
+ except Exception:
79
+ pass
80
+ return find_repo_root()
81
+
82
+
83
+ def _ticket_path(repo_root: Path, index: int) -> Path:
84
+ return repo_root / ".codex-autorunner" / "tickets" / f"TICKET-{index:03d}.md"
85
+
86
+
87
+ def _parse_target(repo_root: Path, raw: str) -> _Target:
88
+ target = (raw or "").strip()
89
+ if not target:
90
+ raise HTTPException(status_code=400, detail="target is required")
91
+
92
+ if target.lower().startswith("ticket:"):
93
+ suffix = target.split(":", 1)[1].strip()
94
+ if not suffix.isdigit():
95
+ raise HTTPException(status_code=400, detail="invalid ticket target")
96
+ idx = int(suffix)
97
+ if idx <= 0:
98
+ raise HTTPException(status_code=400, detail="invalid ticket target")
99
+ path = _ticket_path(repo_root, idx)
100
+ rel = (
101
+ str(path.relative_to(repo_root))
102
+ if path.is_relative_to(repo_root)
103
+ else str(path)
104
+ )
105
+ return _Target(
106
+ target=f"ticket:{idx}",
107
+ kind="ticket",
108
+ id=f"{idx:03d}",
109
+ path=path,
110
+ rel_path=rel,
111
+ state_key=f"ticket_{idx:03d}",
112
+ )
113
+
114
+ if target.lower().startswith("workspace:"):
115
+ suffix_raw = target.split(":", 1)[1].strip()
116
+ if not suffix_raw:
117
+ raise HTTPException(status_code=400, detail="invalid workspace target")
118
+
119
+ # Allow legacy kind-only targets (active_context/decisions/spec)
120
+ if suffix_raw.lower() in WORKSPACE_DOC_KINDS:
121
+ path = workspace_doc_path(repo_root, suffix_raw)
122
+ rel_suffix = f"{suffix_raw}.md"
123
+ else:
124
+ try:
125
+ path, rel_suffix = normalize_workspace_rel_path(repo_root, suffix_raw)
126
+ except ValueError as exc:
127
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
128
+
129
+ rel = (
130
+ str(path.relative_to(repo_root))
131
+ if path.is_relative_to(repo_root)
132
+ else str(path)
133
+ )
134
+ return _Target(
135
+ target=f"workspace:{rel_suffix}",
136
+ kind="workspace",
137
+ id=rel_suffix,
138
+ path=path,
139
+ rel_path=rel,
140
+ state_key=f"workspace_{rel_suffix.replace('/', '_')}",
141
+ )
142
+
143
+ raise HTTPException(status_code=400, detail="invalid target")
144
+
145
+
146
+ def _read_file(path: Path) -> str:
147
+ if not path.exists():
148
+ return ""
149
+ return path.read_text(encoding="utf-8")
150
+
151
+
152
+ def _build_patch(rel_path: str, before: str, after: str) -> str:
153
+ diff = difflib.unified_diff(
154
+ before.splitlines(),
155
+ after.splitlines(),
156
+ fromfile=f"a/{rel_path}",
157
+ tofile=f"b/{rel_path}",
158
+ lineterm="",
159
+ )
160
+ return "\n".join(diff)
161
+
162
+
163
+ def build_file_chat_routes() -> APIRouter:
164
+ router = APIRouter(prefix="/api", tags=["file-chat"])
165
+ _active_chats: Dict[str, asyncio.Event] = {}
166
+ _chat_lock = asyncio.Lock()
167
+
168
+ async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
169
+ async with _chat_lock:
170
+ if key not in _active_chats:
171
+ _active_chats[key] = asyncio.Event()
172
+ return _active_chats[key]
173
+
174
+ async def _clear_interrupt_event(key: str) -> None:
175
+ async with _chat_lock:
176
+ _active_chats.pop(key, None)
177
+
178
+ @router.post("/file-chat")
179
+ async def chat_file(request: Request):
180
+ """Chat with a file target - optionally streams SSE events."""
181
+ body = await request.json()
182
+ target_raw = body.get("target")
183
+ message = (body.get("message") or "").strip()
184
+ stream = bool(body.get("stream", False))
185
+ agent = body.get("agent", "codex")
186
+ model = body.get("model")
187
+ reasoning = body.get("reasoning")
188
+
189
+ if not message:
190
+ raise HTTPException(status_code=400, detail="message is required")
191
+
192
+ repo_root = _resolve_repo_root(request)
193
+ target = _parse_target(repo_root, str(target_raw or ""))
194
+
195
+ # Ensure target directory exists for workspace docs (write on demand)
196
+ if target.kind == "workspace":
197
+ target.path.parent.mkdir(parents=True, exist_ok=True)
198
+
199
+ # Concurrency guard per target
200
+ async with _chat_lock:
201
+ existing = _active_chats.get(target.state_key)
202
+ if existing is not None and not existing.is_set():
203
+ raise HTTPException(status_code=409, detail="File chat already running")
204
+ _active_chats[target.state_key] = asyncio.Event()
205
+
206
+ if stream:
207
+ return StreamingResponse(
208
+ _stream_file_chat(
209
+ request,
210
+ repo_root,
211
+ target,
212
+ message,
213
+ agent=agent,
214
+ model=model,
215
+ reasoning=reasoning,
216
+ ),
217
+ media_type="text/event-stream",
218
+ headers=SSE_HEADERS,
219
+ )
220
+
221
+ try:
222
+ result = await _execute_file_chat(
223
+ request,
224
+ repo_root,
225
+ target,
226
+ message,
227
+ agent=agent,
228
+ model=model,
229
+ reasoning=reasoning,
230
+ )
231
+ return result
232
+ finally:
233
+ await _clear_interrupt_event(target.state_key)
234
+
235
+ async def _stream_file_chat(
236
+ request: Request,
237
+ repo_root: Path,
238
+ target: _Target,
239
+ message: str,
240
+ *,
241
+ agent: str = "codex",
242
+ model: Optional[str] = None,
243
+ reasoning: Optional[str] = None,
244
+ ) -> AsyncIterator[str]:
245
+ yield format_sse("status", {"status": "queued"})
246
+ try:
247
+ result = await _execute_file_chat(
248
+ request,
249
+ repo_root,
250
+ target,
251
+ message,
252
+ agent=agent,
253
+ model=model,
254
+ reasoning=reasoning,
255
+ )
256
+ if result.get("status") == "ok":
257
+ raw_events = result.pop("raw_events", []) or []
258
+ for event in raw_events:
259
+ yield format_sse("app-server", event)
260
+ yield format_sse("update", result)
261
+ yield format_sse("done", {"status": "ok"})
262
+ elif result.get("status") == "interrupted":
263
+ yield format_sse(
264
+ "interrupted",
265
+ {"detail": result.get("detail") or "File chat interrupted"},
266
+ )
267
+ else:
268
+ yield format_sse(
269
+ "error", {"detail": result.get("detail") or "File chat failed"}
270
+ )
271
+ except Exception:
272
+ logger.exception("file chat stream failed")
273
+ yield format_sse("error", {"detail": "File chat failed"})
274
+ finally:
275
+ await _clear_interrupt_event(target.state_key)
276
+
277
+ async def _execute_file_chat(
278
+ request: Request,
279
+ repo_root: Path,
280
+ target: _Target,
281
+ message: str,
282
+ *,
283
+ agent: str = "codex",
284
+ model: Optional[str] = None,
285
+ reasoning: Optional[str] = None,
286
+ ) -> Dict[str, Any]:
287
+ supervisor = getattr(request.app.state, "app_server_supervisor", None)
288
+ threads = getattr(request.app.state, "app_server_threads", None)
289
+ opencode = getattr(request.app.state, "opencode_supervisor", None)
290
+ engine = getattr(request.app.state, "engine", None)
291
+ stall_timeout_seconds = None
292
+ try:
293
+ stall_timeout_seconds = (
294
+ engine.config.opencode.session_stall_timeout_seconds
295
+ if engine is not None
296
+ else None
297
+ )
298
+ except Exception:
299
+ stall_timeout_seconds = None
300
+ if supervisor is None and opencode is None:
301
+ raise FileChatError("No agent supervisor available for file chat")
302
+
303
+ before = _read_file(target.path)
304
+ base_hash = _hash_content(before)
305
+
306
+ prompt = (
307
+ "You are editing a single file in Codex AutoRunner.\n\n"
308
+ f"Target: {target.target}\n"
309
+ f"Path: {target.rel_path}\n\n"
310
+ "Instructions:\n"
311
+ "- This run is non-interactive. Do not ask the user questions.\n"
312
+ "- Edit ONLY the target file.\n"
313
+ "- If no changes are needed, explain why without editing the file.\n"
314
+ "- Respond with a short summary of what you did.\n\n"
315
+ "User request:\n"
316
+ f"{message}\n\n"
317
+ "<FILE_CONTENT>\n"
318
+ f"{before[:12000]}\n"
319
+ "</FILE_CONTENT>\n"
320
+ )
321
+
322
+ interrupt_event = await _get_or_create_interrupt_event(target.state_key)
323
+ if interrupt_event.is_set():
324
+ return {"status": "interrupted", "detail": "File chat interrupted"}
325
+
326
+ try:
327
+ agent_id = validate_agent_id(agent or "")
328
+ except ValueError:
329
+ agent_id = "codex"
330
+
331
+ thread_key = f"file_chat.{target.state_key}"
332
+
333
+ if agent_id == "opencode":
334
+ if opencode is None:
335
+ return {"status": "error", "detail": "OpenCode supervisor unavailable"}
336
+ result = await _execute_opencode(
337
+ opencode,
338
+ repo_root,
339
+ prompt,
340
+ interrupt_event,
341
+ model=model,
342
+ reasoning=reasoning,
343
+ thread_registry=threads,
344
+ thread_key=thread_key,
345
+ stall_timeout_seconds=stall_timeout_seconds,
346
+ )
347
+ else:
348
+ if supervisor is None:
349
+ return {
350
+ "status": "error",
351
+ "detail": "App-server supervisor unavailable",
352
+ }
353
+ result = await _execute_app_server(
354
+ supervisor,
355
+ repo_root,
356
+ prompt,
357
+ interrupt_event,
358
+ model=model,
359
+ reasoning=reasoning,
360
+ thread_registry=threads,
361
+ thread_key=thread_key,
362
+ )
363
+
364
+ if result.get("status") != "ok":
365
+ return result
366
+
367
+ after = _read_file(target.path)
368
+
369
+ # Restore original content; store draft for apply/discard
370
+ if after != before:
371
+ atomic_write(target.path, before)
372
+
373
+ agent_message = result.get("agent_message", "File updated")
374
+ response_text = result.get("message", agent_message)
375
+
376
+ if after != before:
377
+ patch = _build_patch(target.rel_path, before, after)
378
+ state = _load_state(repo_root)
379
+ drafts = (
380
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
381
+ )
382
+ drafts[target.state_key] = {
383
+ "content": after,
384
+ "patch": patch,
385
+ "agent_message": agent_message,
386
+ "created_at": now_iso(),
387
+ "base_hash": base_hash,
388
+ "target": target.target,
389
+ "rel_path": target.rel_path,
390
+ }
391
+ state["drafts"] = drafts
392
+ _save_state(repo_root, state)
393
+ return {
394
+ "status": "ok",
395
+ "target": target.target,
396
+ "agent_message": agent_message,
397
+ "message": response_text,
398
+ "has_draft": True,
399
+ "patch": patch,
400
+ "content": after,
401
+ "base_hash": base_hash,
402
+ "created_at": drafts[target.state_key]["created_at"],
403
+ **(
404
+ {"raw_events": result.get("raw_events")}
405
+ if result.get("raw_events")
406
+ else {}
407
+ ),
408
+ }
409
+
410
+ return {
411
+ "status": "ok",
412
+ "target": target.target,
413
+ "agent_message": agent_message,
414
+ "message": response_text,
415
+ "has_draft": False,
416
+ **(
417
+ {"raw_events": result.get("raw_events")}
418
+ if result.get("raw_events")
419
+ else {}
420
+ ),
421
+ }
422
+
423
+ async def _execute_app_server(
424
+ supervisor: Any,
425
+ repo_root: Path,
426
+ prompt: str,
427
+ interrupt_event: asyncio.Event,
428
+ *,
429
+ model: Optional[str] = None,
430
+ reasoning: Optional[str] = None,
431
+ thread_registry: Optional[Any] = None,
432
+ thread_key: Optional[str] = None,
433
+ ) -> Dict[str, Any]:
434
+ client = await supervisor.get_client(repo_root)
435
+
436
+ thread_id = None
437
+ if thread_registry is not None and thread_key:
438
+ thread_id = thread_registry.get_thread_id(thread_key)
439
+ if thread_id:
440
+ try:
441
+ await client.thread_resume(thread_id)
442
+ except Exception:
443
+ thread_id = None
444
+
445
+ if not thread_id:
446
+ thread = await client.thread_start(str(repo_root))
447
+ thread_id = thread.get("id")
448
+ if not isinstance(thread_id, str) or not thread_id:
449
+ raise FileChatError("App-server did not return a thread id")
450
+ if thread_registry is not None and thread_key:
451
+ thread_registry.set_thread_id(thread_key, thread_id)
452
+
453
+ turn_kwargs: Dict[str, Any] = {}
454
+ if model:
455
+ turn_kwargs["model"] = model
456
+ if reasoning:
457
+ turn_kwargs["effort"] = reasoning
458
+
459
+ handle = await client.turn_start(
460
+ thread_id,
461
+ prompt,
462
+ approval_policy="on-request",
463
+ sandbox_policy="dangerFullAccess",
464
+ **turn_kwargs,
465
+ )
466
+
467
+ turn_task = asyncio.create_task(handle.wait(timeout=None))
468
+ timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
469
+ interrupt_task = asyncio.create_task(interrupt_event.wait())
470
+ try:
471
+ done, _ = await asyncio.wait(
472
+ {turn_task, timeout_task, interrupt_task},
473
+ return_when=asyncio.FIRST_COMPLETED,
474
+ )
475
+ if timeout_task in done:
476
+ turn_task.cancel()
477
+ return {"status": "error", "detail": "File chat timed out"}
478
+ if interrupt_task in done:
479
+ turn_task.cancel()
480
+ return {"status": "interrupted", "detail": "File chat interrupted"}
481
+ turn_result = await turn_task
482
+ finally:
483
+ timeout_task.cancel()
484
+ interrupt_task.cancel()
485
+
486
+ if getattr(turn_result, "errors", None):
487
+ errors = turn_result.errors
488
+ raise FileChatError(errors[-1] if errors else "App-server error")
489
+
490
+ output = "\n".join(getattr(turn_result, "agent_messages", []) or []).strip()
491
+ agent_message = _parse_agent_message(output)
492
+ raw_events = getattr(turn_result, "raw_events", []) or []
493
+ return {
494
+ "status": "ok",
495
+ "agent_message": agent_message,
496
+ "message": output,
497
+ "raw_events": raw_events,
498
+ }
499
+
500
+ async def _execute_opencode(
501
+ supervisor: Any,
502
+ repo_root: Path,
503
+ prompt: str,
504
+ interrupt_event: asyncio.Event,
505
+ *,
506
+ model: Optional[str] = None,
507
+ reasoning: Optional[str] = None,
508
+ thread_registry: Optional[Any] = None,
509
+ thread_key: Optional[str] = None,
510
+ stall_timeout_seconds: Optional[float] = None,
511
+ ) -> Dict[str, Any]:
512
+ from ....agents.opencode.runtime import (
513
+ PERMISSION_ALLOW,
514
+ collect_opencode_output,
515
+ extract_session_id,
516
+ parse_message_response,
517
+ split_model_id,
518
+ )
519
+
520
+ client = await supervisor.get_client(repo_root)
521
+ session_id = None
522
+ if thread_registry is not None and thread_key:
523
+ session_id = thread_registry.get_thread_id(thread_key)
524
+ if not session_id:
525
+ session = await client.create_session(directory=str(repo_root))
526
+ session_id = extract_session_id(session, allow_fallback_id=True)
527
+ if not isinstance(session_id, str) or not session_id:
528
+ raise FileChatError("OpenCode did not return a session id")
529
+ if thread_registry is not None and thread_key:
530
+ thread_registry.set_thread_id(thread_key, session_id)
531
+
532
+ model_payload = split_model_id(model)
533
+ await supervisor.mark_turn_started(repo_root)
534
+
535
+ ready_event = asyncio.Event()
536
+ output_task = asyncio.create_task(
537
+ collect_opencode_output(
538
+ client,
539
+ session_id=session_id,
540
+ workspace_path=str(repo_root),
541
+ model_payload=model_payload,
542
+ permission_policy=PERMISSION_ALLOW,
543
+ question_policy="auto_first_option",
544
+ should_stop=interrupt_event.is_set,
545
+ ready_event=ready_event,
546
+ stall_timeout_seconds=stall_timeout_seconds,
547
+ )
548
+ )
549
+ with contextlib.suppress(asyncio.TimeoutError):
550
+ await asyncio.wait_for(ready_event.wait(), timeout=2.0)
551
+
552
+ prompt_task = asyncio.create_task(
553
+ client.prompt_async(
554
+ session_id,
555
+ message=prompt,
556
+ model=model_payload,
557
+ variant=reasoning,
558
+ )
559
+ )
560
+ timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
561
+ interrupt_task = asyncio.create_task(interrupt_event.wait())
562
+ try:
563
+ prompt_response = None
564
+ try:
565
+ prompt_response = await prompt_task
566
+ except Exception as exc:
567
+ interrupt_event.set()
568
+ output_task.cancel()
569
+ raise FileChatError(f"OpenCode prompt failed: {exc}") from exc
570
+
571
+ done, _ = await asyncio.wait(
572
+ {output_task, timeout_task, interrupt_task},
573
+ return_when=asyncio.FIRST_COMPLETED,
574
+ )
575
+ if timeout_task in done:
576
+ output_task.cancel()
577
+ return {"status": "error", "detail": "File chat timed out"}
578
+ if interrupt_task in done:
579
+ output_task.cancel()
580
+ return {"status": "interrupted", "detail": "File chat interrupted"}
581
+ output_result = await output_task
582
+ if (not output_result.text) and prompt_response is not None:
583
+ fallback = parse_message_response(prompt_response)
584
+ if fallback.text:
585
+ output_result = type(output_result)(
586
+ text=fallback.text, error=fallback.error
587
+ )
588
+ finally:
589
+ timeout_task.cancel()
590
+ interrupt_task.cancel()
591
+ await supervisor.mark_turn_finished(repo_root)
592
+
593
+ if output_result.error:
594
+ raise FileChatError(output_result.error)
595
+ agent_message = _parse_agent_message(output_result.text)
596
+ return {
597
+ "status": "ok",
598
+ "agent_message": agent_message,
599
+ "message": output_result.text,
600
+ }
601
+
602
+ def _parse_agent_message(output: str) -> str:
603
+ text = (output or "").strip()
604
+ if not text:
605
+ return "File updated via chat."
606
+ for line in text.splitlines():
607
+ if line.lower().startswith("agent:"):
608
+ return line[len("agent:") :].strip() or "File updated via chat."
609
+ first_line = text.splitlines()[0].strip()
610
+ return (first_line[:97] + "...") if len(first_line) > 100 else first_line
611
+
612
+ @router.get("/file-chat/pending")
613
+ async def pending_file_patch(request: Request, target: str):
614
+ repo_root = _resolve_repo_root(request)
615
+ resolved = _parse_target(repo_root, target)
616
+ state = _load_state(repo_root)
617
+ drafts = (
618
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
619
+ )
620
+ draft = drafts.get(resolved.state_key)
621
+ if not draft:
622
+ raise HTTPException(status_code=404, detail="No pending patch")
623
+ current_content = _read_file(resolved.path)
624
+ current_hash = _hash_content(current_content)
625
+ return {
626
+ "status": "ok",
627
+ "target": resolved.target,
628
+ "patch": draft.get("patch", ""),
629
+ "content": draft.get("content", ""),
630
+ "agent_message": draft.get("agent_message", ""),
631
+ "created_at": draft.get("created_at", ""),
632
+ "base_hash": draft.get("base_hash", ""),
633
+ "current_hash": current_hash,
634
+ "is_stale": draft.get("base_hash") not in (None, "")
635
+ and draft.get("base_hash") != current_hash,
636
+ }
637
+
638
+ @router.post("/file-chat/apply")
639
+ async def apply_file_patch(request: Request):
640
+ body = await request.json()
641
+ repo_root = _resolve_repo_root(request)
642
+ resolved = _parse_target(repo_root, str(body.get("target") or ""))
643
+ force = bool(body.get("force", False))
644
+ state = _load_state(repo_root)
645
+ drafts = (
646
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
647
+ )
648
+ draft = drafts.get(resolved.state_key)
649
+ if not draft:
650
+ raise HTTPException(status_code=404, detail="No pending patch")
651
+
652
+ current = _read_file(resolved.path)
653
+ if (
654
+ not force
655
+ and draft.get("base_hash")
656
+ and _hash_content(current) != draft["base_hash"]
657
+ ):
658
+ raise HTTPException(
659
+ status_code=409,
660
+ detail="File changed since draft created; reload before applying.",
661
+ )
662
+
663
+ content = draft.get("content", "")
664
+ resolved.path.parent.mkdir(parents=True, exist_ok=True)
665
+ atomic_write(resolved.path, content)
666
+
667
+ drafts.pop(resolved.state_key, None)
668
+ state["drafts"] = drafts
669
+ _save_state(repo_root, state)
670
+
671
+ return {
672
+ "status": "ok",
673
+ "target": resolved.target,
674
+ "content": _read_file(resolved.path),
675
+ "agent_message": draft.get("agent_message", "Draft applied"),
676
+ }
677
+
678
+ @router.post("/file-chat/discard")
679
+ async def discard_file_patch(request: Request):
680
+ body = await request.json()
681
+ repo_root = _resolve_repo_root(request)
682
+ resolved = _parse_target(repo_root, str(body.get("target") or ""))
683
+ state = _load_state(repo_root)
684
+ drafts = (
685
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
686
+ )
687
+ drafts.pop(resolved.state_key, None)
688
+ state["drafts"] = drafts
689
+ _save_state(repo_root, state)
690
+ return {
691
+ "status": "ok",
692
+ "target": resolved.target,
693
+ "content": _read_file(resolved.path),
694
+ }
695
+
696
+ @router.post("/file-chat/interrupt")
697
+ async def interrupt_file_chat(request: Request):
698
+ body = await request.json()
699
+ repo_root = _resolve_repo_root(request)
700
+ resolved = _parse_target(repo_root, str(body.get("target") or ""))
701
+ async with _chat_lock:
702
+ ev = _active_chats.get(resolved.state_key)
703
+ if ev is None:
704
+ return {"status": "ok", "detail": "No active chat to interrupt"}
705
+ ev.set()
706
+ return {"status": "interrupted", "detail": "File chat interrupted"}
707
+
708
+ # Legacy ticket endpoints (thin wrappers) to keep older UIs working.
709
+
710
+ @router.post("/tickets/{index}/chat")
711
+ async def chat_ticket(index: int, request: Request):
712
+ body = await request.json()
713
+ message = (body.get("message") or "").strip()
714
+ stream = bool(body.get("stream", False))
715
+ agent = body.get("agent", "codex")
716
+ model = body.get("model")
717
+ reasoning = body.get("reasoning")
718
+
719
+ if not message:
720
+ raise HTTPException(status_code=400, detail="message is required")
721
+
722
+ repo_root = _resolve_repo_root(request)
723
+ target = _parse_target(repo_root, f"ticket:{int(index)}")
724
+
725
+ async with _chat_lock:
726
+ existing = _active_chats.get(target.state_key)
727
+ if existing is not None and not existing.is_set():
728
+ raise HTTPException(
729
+ status_code=409, detail="Ticket chat already running"
730
+ )
731
+ _active_chats[target.state_key] = asyncio.Event()
732
+
733
+ if stream:
734
+ return StreamingResponse(
735
+ _stream_file_chat(
736
+ request,
737
+ repo_root,
738
+ target,
739
+ message,
740
+ agent=agent,
741
+ model=model,
742
+ reasoning=reasoning,
743
+ ),
744
+ media_type="text/event-stream",
745
+ headers=SSE_HEADERS,
746
+ )
747
+
748
+ try:
749
+ return await _execute_file_chat(
750
+ request,
751
+ repo_root,
752
+ target,
753
+ message,
754
+ agent=agent,
755
+ model=model,
756
+ reasoning=reasoning,
757
+ )
758
+ finally:
759
+ await _clear_interrupt_event(target.state_key)
760
+
761
+ @router.get("/tickets/{index}/chat/pending")
762
+ async def pending_ticket_patch(index: int, request: Request):
763
+ return await pending_file_patch(request, target=f"ticket:{int(index)}")
764
+
765
+ @router.post("/tickets/{index}/chat/apply")
766
+ async def apply_ticket_patch(index: int, request: Request):
767
+ try:
768
+ body = await request.json()
769
+ except Exception:
770
+ body = {}
771
+ repo_root = _resolve_repo_root(request)
772
+ target = _parse_target(repo_root, f"ticket:{int(index)}")
773
+ force = bool(body.get("force", False)) if isinstance(body, dict) else False
774
+ state = _load_state(repo_root)
775
+ drafts = (
776
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
777
+ )
778
+ draft = drafts.get(target.state_key)
779
+ if not draft:
780
+ raise HTTPException(status_code=404, detail="No pending patch")
781
+
782
+ current = _read_file(target.path)
783
+ if (
784
+ not force
785
+ and draft.get("base_hash")
786
+ and _hash_content(current) != draft["base_hash"]
787
+ ):
788
+ raise HTTPException(
789
+ status_code=409,
790
+ detail="Ticket changed since draft created; reload before applying.",
791
+ )
792
+
793
+ content = draft.get("content", "")
794
+ target.path.parent.mkdir(parents=True, exist_ok=True)
795
+ atomic_write(target.path, content)
796
+
797
+ drafts.pop(target.state_key, None)
798
+ state["drafts"] = drafts
799
+ _save_state(repo_root, state)
800
+
801
+ return {
802
+ "status": "ok",
803
+ "index": int(index),
804
+ "content": _read_file(target.path),
805
+ "agent_message": draft.get("agent_message", "Draft applied"),
806
+ }
807
+
808
+ @router.post("/tickets/{index}/chat/discard")
809
+ async def discard_ticket_patch(index: int, request: Request):
810
+ repo_root = _resolve_repo_root(request)
811
+ target = _parse_target(repo_root, f"ticket:{int(index)}")
812
+ state = _load_state(repo_root)
813
+ drafts = (
814
+ state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
815
+ )
816
+ drafts.pop(target.state_key, None)
817
+ state["drafts"] = drafts
818
+ _save_state(repo_root, state)
819
+ return {
820
+ "status": "ok",
821
+ "index": int(index),
822
+ "content": _read_file(target.path),
823
+ }
824
+
825
+ @router.post("/tickets/{index}/chat/interrupt")
826
+ async def interrupt_ticket_chat(index: int, request: Request):
827
+ repo_root = _resolve_repo_root(request)
828
+ target = _parse_target(repo_root, f"ticket:{int(index)}")
829
+ async with _chat_lock:
830
+ ev = _active_chats.get(target.state_key)
831
+ if ev is None:
832
+ return {"status": "ok", "detail": "No active chat to interrupt"}
833
+ ev.set()
834
+ return {"status": "interrupted", "detail": "Ticket chat interrupted"}
835
+
836
+ return router