codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__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 (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -14,13 +14,14 @@ import difflib
14
14
  import logging
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
- from typing import Any, AsyncIterator, Dict, Optional
17
+ from typing import Any, AsyncIterator, Callable, Dict, Optional
18
18
 
19
19
  from fastapi import APIRouter, HTTPException, Request
20
20
  from fastapi.responses import StreamingResponse
21
21
 
22
22
  from ....agents.registry import validate_agent_id
23
23
  from ....core import drafts as draft_utils
24
+ from ....core.context_awareness import CAR_AWARENESS_BLOCK, format_file_role_addendum
24
25
  from ....core.state import now_iso
25
26
  from ....core.utils import atomic_write, find_repo_root
26
27
  from ....integrations.app_server.event_buffer import format_sse
@@ -140,7 +141,49 @@ def _parse_target(repo_root: Path, raw: str) -> _Target:
140
141
  state_key=f"workspace_{rel_suffix.replace('/', '_')}",
141
142
  )
142
143
 
143
- raise HTTPException(status_code=400, detail="invalid target")
144
+ raise HTTPException(status_code=400, detail=f"invalid target: {target}")
145
+
146
+
147
+ def _build_file_chat_prompt(*, target: _Target, message: str, before: str) -> str:
148
+ if target.kind == "ticket":
149
+ file_role_context = (
150
+ f"{format_file_role_addendum('ticket', target.rel_path)}\n"
151
+ "Edits here change what the ticket flow agent will do; keep YAML "
152
+ "frontmatter valid."
153
+ )
154
+ elif target.kind == "workspace":
155
+ file_role_context = (
156
+ f"{format_file_role_addendum('workspace', target.rel_path)}\n"
157
+ "These docs act as shared memory across ticket turns."
158
+ )
159
+ else:
160
+ file_role_context = format_file_role_addendum("other", target.rel_path)
161
+
162
+ return (
163
+ f"{CAR_AWARENESS_BLOCK}\n\n"
164
+ "<file_role_context>\n"
165
+ f"{file_role_context}\n"
166
+ "</file_role_context>\n\n"
167
+ "You are editing a single file in Codex Autorunner.\n\n"
168
+ "<target>\n"
169
+ f"{target.target}\n"
170
+ "</target>\n\n"
171
+ "<path>\n"
172
+ f"{target.rel_path}\n"
173
+ "</path>\n\n"
174
+ "<instructions>\n"
175
+ "- This is a single-turn edit request. Don’t ask the user questions.\n"
176
+ "- You may read other files for context, but only modify the target file.\n"
177
+ "- If no changes are needed, explain why without editing the file.\n"
178
+ "- Respond with a short summary of what you did.\n"
179
+ "</instructions>\n\n"
180
+ "<user_request>\n"
181
+ f"{message}\n"
182
+ "</user_request>\n\n"
183
+ "<FILE_CONTENT>\n"
184
+ f"{before[:12000]}\n"
185
+ "</FILE_CONTENT>\n"
186
+ )
144
187
 
145
188
 
146
189
  def _read_file(path: Path) -> str:
@@ -164,6 +207,10 @@ def build_file_chat_routes() -> APIRouter:
164
207
  router = APIRouter(prefix="/api", tags=["file-chat"])
165
208
  _active_chats: Dict[str, asyncio.Event] = {}
166
209
  _chat_lock = asyncio.Lock()
210
+ _turn_lock = asyncio.Lock()
211
+ _current_by_target: Dict[str, Dict[str, Any]] = {}
212
+ _current_by_client: Dict[str, Dict[str, Any]] = {}
213
+ _last_by_client: Dict[str, Dict[str, Any]] = {}
167
214
 
168
215
  async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
169
216
  async with _chat_lock:
@@ -175,6 +222,61 @@ def build_file_chat_routes() -> APIRouter:
175
222
  async with _chat_lock:
176
223
  _active_chats.pop(key, None)
177
224
 
225
+ async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
226
+ async with _turn_lock:
227
+ state: Dict[str, Any] = {
228
+ "client_turn_id": client_turn_id or "",
229
+ "target": target.target,
230
+ "status": "starting",
231
+ "agent": None,
232
+ "thread_id": None,
233
+ "turn_id": None,
234
+ }
235
+ _current_by_target[target.state_key] = state
236
+ if client_turn_id:
237
+ _current_by_client[client_turn_id] = state
238
+
239
+ async def _update_turn_state(target: _Target, **updates: Any) -> None:
240
+ async with _turn_lock:
241
+ state = _current_by_target.get(target.state_key)
242
+ if not state:
243
+ return
244
+ for key, value in updates.items():
245
+ if value is None:
246
+ continue
247
+ state[key] = value
248
+ cid = state.get("client_turn_id") or ""
249
+ if cid:
250
+ _current_by_client[cid] = state
251
+
252
+ async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
253
+ async with _turn_lock:
254
+ state = _current_by_target.pop(target.state_key, None)
255
+ cid = ""
256
+ if state:
257
+ cid = state.get("client_turn_id", "") or ""
258
+ if cid:
259
+ _current_by_client.pop(cid, None)
260
+ _last_by_client[cid] = dict(result or {})
261
+
262
+ async def _active_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
263
+ if not client_turn_id:
264
+ return {}
265
+ async with _turn_lock:
266
+ return dict(_current_by_client.get(client_turn_id, {}))
267
+
268
+ async def _last_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
269
+ if not client_turn_id:
270
+ return {}
271
+ async with _turn_lock:
272
+ return dict(_last_by_client.get(client_turn_id, {}))
273
+
274
+ @router.get("/file-chat/active")
275
+ async def file_chat_active(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
276
+ current = await _active_for_client(client_turn_id)
277
+ last = await _last_for_client(client_turn_id)
278
+ return {"active": bool(current), "current": current, "last_result": last}
279
+
178
280
  @router.post("/file-chat")
179
281
  async def chat_file(request: Request):
180
282
  """Chat with a file target - optionally streams SSE events."""
@@ -185,6 +287,7 @@ def build_file_chat_routes() -> APIRouter:
185
287
  agent = body.get("agent", "codex")
186
288
  model = body.get("model")
187
289
  reasoning = body.get("reasoning")
290
+ client_turn_id = (body.get("client_turn_id") or "").strip() or None
188
291
 
189
292
  if not message:
190
293
  raise HTTPException(status_code=400, detail="message is required")
@@ -203,6 +306,8 @@ def build_file_chat_routes() -> APIRouter:
203
306
  raise HTTPException(status_code=409, detail="File chat already running")
204
307
  _active_chats[target.state_key] = asyncio.Event()
205
308
 
309
+ await _begin_turn_state(target, client_turn_id)
310
+
206
311
  if stream:
207
312
  return StreamingResponse(
208
313
  _stream_file_chat(
@@ -213,21 +318,47 @@ def build_file_chat_routes() -> APIRouter:
213
318
  agent=agent,
214
319
  model=model,
215
320
  reasoning=reasoning,
321
+ client_turn_id=client_turn_id,
216
322
  ),
217
323
  media_type="text/event-stream",
218
324
  headers=SSE_HEADERS,
219
325
  )
220
326
 
221
327
  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
- )
328
+ result: Dict[str, Any]
329
+
330
+ async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
331
+ await _update_turn_state(
332
+ target,
333
+ agent=agent_id,
334
+ thread_id=thread_id,
335
+ turn_id=turn_id,
336
+ )
337
+
338
+ try:
339
+ result = await _execute_file_chat(
340
+ request,
341
+ repo_root,
342
+ target,
343
+ message,
344
+ agent=agent,
345
+ model=model,
346
+ reasoning=reasoning,
347
+ on_meta=_on_meta,
348
+ )
349
+ except Exception as exc:
350
+ await _finalize_turn_state(
351
+ target,
352
+ {
353
+ "status": "error",
354
+ "detail": str(exc),
355
+ "client_turn_id": client_turn_id or "",
356
+ },
357
+ )
358
+ raise
359
+ result = dict(result or {})
360
+ result["client_turn_id"] = client_turn_id or ""
361
+ await _finalize_turn_state(target, result)
231
362
  return result
232
363
  finally:
233
364
  await _clear_interrupt_event(target.state_key)
@@ -241,22 +372,62 @@ def build_file_chat_routes() -> APIRouter:
241
372
  agent: str = "codex",
242
373
  model: Optional[str] = None,
243
374
  reasoning: Optional[str] = None,
375
+ client_turn_id: Optional[str] = None,
244
376
  ) -> AsyncIterator[str]:
245
377
  yield format_sse("status", {"status": "queued"})
246
378
  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,
379
+
380
+ async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
381
+ await _update_turn_state(
382
+ target,
383
+ agent=agent_id,
384
+ thread_id=thread_id,
385
+ turn_id=turn_id,
386
+ )
387
+
388
+ run_task = asyncio.create_task(
389
+ _execute_file_chat(
390
+ request,
391
+ repo_root,
392
+ target,
393
+ message,
394
+ agent=agent,
395
+ model=model,
396
+ reasoning=reasoning,
397
+ on_meta=_on_meta,
398
+ )
255
399
  )
400
+
401
+ async def _finalize() -> None:
402
+ result = {"status": "error", "detail": "File chat failed"}
403
+ try:
404
+ result = await run_task
405
+ except Exception as exc:
406
+ logger.exception("file chat task failed")
407
+ result = {
408
+ "status": "error",
409
+ "detail": str(exc) or "File chat failed",
410
+ }
411
+ result = dict(result or {})
412
+ result["client_turn_id"] = client_turn_id or ""
413
+ await _finalize_turn_state(target, result)
414
+
415
+ asyncio.create_task(_finalize())
416
+
417
+ try:
418
+ result = await asyncio.shield(run_task)
419
+ except asyncio.CancelledError:
420
+ # client disconnected; turn continues in background
421
+ return
422
+
256
423
  if result.get("status") == "ok":
257
424
  raw_events = result.pop("raw_events", []) or []
258
425
  for event in raw_events:
259
426
  yield format_sse("app-server", event)
427
+ usage_parts = result.pop("usage_parts", []) or []
428
+ for usage in usage_parts:
429
+ yield format_sse("token_usage", usage)
430
+ result["client_turn_id"] = client_turn_id or ""
260
431
  yield format_sse("update", result)
261
432
  yield format_sse("done", {"status": "ok"})
262
433
  elif result.get("status") == "interrupted":
@@ -283,11 +454,14 @@ def build_file_chat_routes() -> APIRouter:
283
454
  agent: str = "codex",
284
455
  model: Optional[str] = None,
285
456
  reasoning: Optional[str] = None,
457
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
458
+ on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
286
459
  ) -> Dict[str, Any]:
287
460
  supervisor = getattr(request.app.state, "app_server_supervisor", None)
288
461
  threads = getattr(request.app.state, "app_server_threads", None)
289
462
  opencode = getattr(request.app.state, "opencode_supervisor", None)
290
463
  engine = getattr(request.app.state, "engine", None)
464
+ events = getattr(request.app.state, "app_server_events", None)
291
465
  stall_timeout_seconds = None
292
466
  try:
293
467
  stall_timeout_seconds = (
@@ -303,21 +477,7 @@ def build_file_chat_routes() -> APIRouter:
303
477
  before = _read_file(target.path)
304
478
  base_hash = _hash_content(before)
305
479
 
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
- )
480
+ prompt = _build_file_chat_prompt(target=target, message=message, before=before)
321
481
 
322
482
  interrupt_event = await _get_or_create_interrupt_event(target.state_key)
323
483
  if interrupt_event.is_set():
@@ -329,6 +489,7 @@ def build_file_chat_routes() -> APIRouter:
329
489
  agent_id = "codex"
330
490
 
331
491
  thread_key = f"file_chat.{target.state_key}"
492
+ await _update_turn_state(target, status="running", agent=agent_id)
332
493
 
333
494
  if agent_id == "opencode":
334
495
  if opencode is None:
@@ -343,6 +504,8 @@ def build_file_chat_routes() -> APIRouter:
343
504
  thread_registry=threads,
344
505
  thread_key=thread_key,
345
506
  stall_timeout_seconds=stall_timeout_seconds,
507
+ on_meta=on_meta,
508
+ on_usage=on_usage,
346
509
  )
347
510
  else:
348
511
  if supervisor is None:
@@ -355,10 +518,13 @@ def build_file_chat_routes() -> APIRouter:
355
518
  repo_root,
356
519
  prompt,
357
520
  interrupt_event,
521
+ agent_id=agent_id,
358
522
  model=model,
359
523
  reasoning=reasoning,
360
524
  thread_registry=threads,
361
525
  thread_key=thread_key,
526
+ on_meta=on_meta,
527
+ events=events,
362
528
  )
363
529
 
364
530
  if result.get("status") != "ok":
@@ -393,6 +559,7 @@ def build_file_chat_routes() -> APIRouter:
393
559
  return {
394
560
  "status": "ok",
395
561
  "target": target.target,
562
+ "agent": agent_id,
396
563
  "agent_message": agent_message,
397
564
  "message": response_text,
398
565
  "has_draft": True,
@@ -400,6 +567,8 @@ def build_file_chat_routes() -> APIRouter:
400
567
  "content": after,
401
568
  "base_hash": base_hash,
402
569
  "created_at": drafts[target.state_key]["created_at"],
570
+ "thread_id": result.get("thread_id"),
571
+ "turn_id": result.get("turn_id"),
403
572
  **(
404
573
  {"raw_events": result.get("raw_events")}
405
574
  if result.get("raw_events")
@@ -410,9 +579,12 @@ def build_file_chat_routes() -> APIRouter:
410
579
  return {
411
580
  "status": "ok",
412
581
  "target": target.target,
582
+ "agent": agent_id,
413
583
  "agent_message": agent_message,
414
584
  "message": response_text,
415
585
  "has_draft": False,
586
+ "thread_id": result.get("thread_id"),
587
+ "turn_id": result.get("turn_id"),
416
588
  **(
417
589
  {"raw_events": result.get("raw_events")}
418
590
  if result.get("raw_events")
@@ -428,8 +600,11 @@ def build_file_chat_routes() -> APIRouter:
428
600
  *,
429
601
  model: Optional[str] = None,
430
602
  reasoning: Optional[str] = None,
603
+ agent_id: str = "codex",
431
604
  thread_registry: Optional[Any] = None,
432
605
  thread_key: Optional[str] = None,
606
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
607
+ events: Optional[Any] = None,
433
608
  ) -> Dict[str, Any]:
434
609
  client = await supervisor.get_client(repo_root)
435
610
 
@@ -463,6 +638,18 @@ def build_file_chat_routes() -> APIRouter:
463
638
  sandbox_policy="dangerFullAccess",
464
639
  **turn_kwargs,
465
640
  )
641
+ if events is not None:
642
+ try:
643
+ await events.register_turn(thread_id, handle.turn_id)
644
+ except Exception:
645
+ logger.debug("file chat register_turn failed", exc_info=True)
646
+ if on_meta is not None:
647
+ try:
648
+ maybe = on_meta(agent_id, thread_id, handle.turn_id)
649
+ if asyncio.iscoroutine(maybe):
650
+ await maybe
651
+ except Exception:
652
+ logger.debug("file chat meta callback failed", exc_info=True)
466
653
 
467
654
  turn_task = asyncio.create_task(handle.wait(timeout=None))
468
655
  timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
@@ -495,6 +682,9 @@ def build_file_chat_routes() -> APIRouter:
495
682
  "agent_message": agent_message,
496
683
  "message": output,
497
684
  "raw_events": raw_events,
685
+ "thread_id": thread_id,
686
+ "turn_id": getattr(handle, "turn_id", None),
687
+ "agent": agent_id,
498
688
  }
499
689
 
500
690
  async def _execute_opencode(
@@ -508,9 +698,12 @@ def build_file_chat_routes() -> APIRouter:
508
698
  thread_registry: Optional[Any] = None,
509
699
  thread_key: Optional[str] = None,
510
700
  stall_timeout_seconds: Optional[float] = None,
701
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
702
+ on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
511
703
  ) -> Dict[str, Any]:
512
704
  from ....agents.opencode.runtime import (
513
705
  PERMISSION_ALLOW,
706
+ build_turn_id,
514
707
  collect_opencode_output,
515
708
  extract_session_id,
516
709
  parse_message_response,
@@ -529,9 +722,32 @@ def build_file_chat_routes() -> APIRouter:
529
722
  if thread_registry is not None and thread_key:
530
723
  thread_registry.set_thread_id(thread_key, session_id)
531
724
 
725
+ turn_id = build_turn_id(session_id)
726
+ if on_meta is not None:
727
+ try:
728
+ maybe = on_meta("opencode", session_id, turn_id)
729
+ if asyncio.iscoroutine(maybe):
730
+ await maybe
731
+ except Exception:
732
+ logger.debug("file chat opencode meta failed", exc_info=True)
733
+
532
734
  model_payload = split_model_id(model)
533
735
  await supervisor.mark_turn_started(repo_root)
534
736
 
737
+ usage_parts: list[Dict[str, Any]] = []
738
+
739
+ async def _part_handler(
740
+ part_type: str, part: Any, turn_id_arg: Optional[str] | None
741
+ ) -> None:
742
+ if part_type == "usage" and on_usage is not None:
743
+ usage_parts.append(part)
744
+ try:
745
+ maybe = on_usage(part)
746
+ if asyncio.iscoroutine(maybe):
747
+ await maybe
748
+ except Exception:
749
+ logger.debug("file chat usage handler failed", exc_info=True)
750
+
535
751
  ready_event = asyncio.Event()
536
752
  output_task = asyncio.create_task(
537
753
  collect_opencode_output(
@@ -543,6 +759,7 @@ def build_file_chat_routes() -> APIRouter:
543
759
  question_policy="auto_first_option",
544
760
  should_stop=interrupt_event.is_set,
545
761
  ready_event=ready_event,
762
+ part_handler=_part_handler,
546
763
  stall_timeout_seconds=stall_timeout_seconds,
547
764
  )
548
765
  )
@@ -593,11 +810,17 @@ def build_file_chat_routes() -> APIRouter:
593
810
  if output_result.error:
594
811
  raise FileChatError(output_result.error)
595
812
  agent_message = _parse_agent_message(output_result.text)
596
- return {
813
+ result = {
597
814
  "status": "ok",
598
815
  "agent_message": agent_message,
599
816
  "message": output_result.text,
817
+ "thread_id": session_id,
818
+ "turn_id": turn_id,
819
+ "agent": "opencode",
600
820
  }
821
+ if usage_parts:
822
+ result["usage_parts"] = usage_parts
823
+ return result
601
824
 
602
825
  def _parse_agent_message(output: str) -> str:
603
826
  text = (output or "").strip()
@@ -693,6 +916,37 @@ def build_file_chat_routes() -> APIRouter:
693
916
  "content": _read_file(resolved.path),
694
917
  }
695
918
 
919
+ @router.get("/file-chat/turns/{turn_id}/events")
920
+ async def stream_file_chat_turn_events(
921
+ turn_id: str, request: Request, thread_id: str, agent: str = "codex"
922
+ ):
923
+ agent_id = (agent or "").strip().lower()
924
+ if agent_id == "codex":
925
+ events = getattr(request.app.state, "app_server_events", None)
926
+ if events is None:
927
+ raise HTTPException(status_code=404, detail="Events unavailable")
928
+ if not thread_id:
929
+ raise HTTPException(status_code=400, detail="thread_id is required")
930
+ return StreamingResponse(
931
+ events.stream(thread_id, turn_id),
932
+ media_type="text/event-stream",
933
+ headers=SSE_HEADERS,
934
+ )
935
+ if agent_id == "opencode":
936
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
937
+ if supervisor is None:
938
+ raise HTTPException(status_code=404, detail="OpenCode unavailable")
939
+ from ....agents.opencode.harness import OpenCodeHarness
940
+
941
+ harness = OpenCodeHarness(supervisor)
942
+ repo_root = _resolve_repo_root(request)
943
+ return StreamingResponse(
944
+ harness.stream_events(repo_root, thread_id, turn_id),
945
+ media_type="text/event-stream",
946
+ headers=SSE_HEADERS,
947
+ )
948
+ raise HTTPException(status_code=404, detail="Unknown agent")
949
+
696
950
  @router.post("/file-chat/interrupt")
697
951
  async def interrupt_file_chat(request: Request):
698
952
  body = await request.json()
@@ -715,6 +969,7 @@ def build_file_chat_routes() -> APIRouter:
715
969
  agent = body.get("agent", "codex")
716
970
  model = body.get("model")
717
971
  reasoning = body.get("reasoning")
972
+ client_turn_id = (body.get("client_turn_id") or "").strip() or None
718
973
 
719
974
  if not message:
720
975
  raise HTTPException(status_code=400, detail="message is required")
@@ -729,6 +984,7 @@ def build_file_chat_routes() -> APIRouter:
729
984
  status_code=409, detail="Ticket chat already running"
730
985
  )
731
986
  _active_chats[target.state_key] = asyncio.Event()
987
+ await _begin_turn_state(target, client_turn_id)
732
988
 
733
989
  if stream:
734
990
  return StreamingResponse(
@@ -740,13 +996,14 @@ def build_file_chat_routes() -> APIRouter:
740
996
  agent=agent,
741
997
  model=model,
742
998
  reasoning=reasoning,
999
+ client_turn_id=client_turn_id,
743
1000
  ),
744
1001
  media_type="text/event-stream",
745
1002
  headers=SSE_HEADERS,
746
1003
  )
747
1004
 
748
1005
  try:
749
- return await _execute_file_chat(
1006
+ result = await _execute_file_chat(
750
1007
  request,
751
1008
  repo_root,
752
1009
  target,
@@ -755,6 +1012,10 @@ def build_file_chat_routes() -> APIRouter:
755
1012
  model=model,
756
1013
  reasoning=reasoning,
757
1014
  )
1015
+ result = dict(result or {})
1016
+ result["client_turn_id"] = client_turn_id or ""
1017
+ await _finalize_turn_state(target, result)
1018
+ return result
758
1019
  finally:
759
1020
  await _clear_interrupt_event(target.state_key)
760
1021