codex-autorunner 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) 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 +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -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 +496 -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/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ 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
@@ -140,7 +140,70 @@ def _parse_target(repo_root: Path, raw: str) -> _Target:
140
140
  state_key=f"workspace_{rel_suffix.replace('/', '_')}",
141
141
  )
142
142
 
143
- raise HTTPException(status_code=400, detail="invalid target")
143
+ raise HTTPException(status_code=400, detail=f"invalid target: {target}")
144
+
145
+
146
+ def _build_file_chat_prompt(*, target: _Target, message: str, before: str) -> str:
147
+ if target.kind == "ticket":
148
+ file_role_context = (
149
+ "This file is a CAR ticket. Ticket flow processes "
150
+ "`.codex-autorunner/tickets/TICKET-###*.md` in numeric order.\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
+ "This file is a CAR workspace doc under `.codex-autorunner/workspace/`."
157
+ " These docs act as shared memory across ticket turns."
158
+ )
159
+ else:
160
+ file_role_context = (
161
+ "This file is a normal repo file (not a CAR ticket/workspace doc)."
162
+ )
163
+
164
+ return (
165
+ "<injected context>\n"
166
+ "You are operating inside a Codex Autorunner (CAR) managed repo.\n\n"
167
+ "CAR’s durable control-plane lives under `.codex-autorunner/`:\n"
168
+ "- `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing "
169
+ "(ticket/workspace conventions + helper scripts).\n"
170
+ "- `.codex-autorunner/tickets/` — ordered ticket queue "
171
+ "(`TICKET-###*.md`) used by the ticket flow runner.\n"
172
+ "- `.codex-autorunner/workspace/` — shared context docs:\n"
173
+ " - `active_context.md` — current “north star” context; kept fresh "
174
+ "for ongoing work.\n"
175
+ " - `spec.md` — longer spec / acceptance criteria when needed.\n"
176
+ " - `decisions.md` — prior decisions / tradeoffs when relevant.\n\n"
177
+ "Intent signals: if the user mentions tickets, “dispatch”, “resume”, "
178
+ "workspace docs, or `.codex-autorunner/`, they are likely referring "
179
+ "to CAR artifacts/workflow rather than generic repo files.\n\n"
180
+ "Use the above as orientation. If you need the operational details "
181
+ "(exact helper commands, what CAR auto-generates), read "
182
+ "`.codex-autorunner/ABOUT_CAR.md`.\n"
183
+ "</injected context>\n\n"
184
+ "<file_role_context>\n"
185
+ f"{file_role_context}\n"
186
+ "</file_role_context>\n\n"
187
+ "You are editing a single file in Codex Autorunner.\n\n"
188
+ "<target>\n"
189
+ f"{target.target}\n"
190
+ "</target>\n\n"
191
+ "<path>\n"
192
+ f"{target.rel_path}\n"
193
+ "</path>\n\n"
194
+ "<instructions>\n"
195
+ "- This is a single-turn edit request. Don’t ask the user questions.\n"
196
+ "- You may read other files for context, but only modify the target file.\n"
197
+ "- If no changes are needed, explain why without editing the file.\n"
198
+ "- Respond with a short summary of what you did.\n"
199
+ "</instructions>\n\n"
200
+ "<user_request>\n"
201
+ f"{message}\n"
202
+ "</user_request>\n\n"
203
+ "<FILE_CONTENT>\n"
204
+ f"{before[:12000]}\n"
205
+ "</FILE_CONTENT>\n"
206
+ )
144
207
 
145
208
 
146
209
  def _read_file(path: Path) -> str:
@@ -164,6 +227,10 @@ def build_file_chat_routes() -> APIRouter:
164
227
  router = APIRouter(prefix="/api", tags=["file-chat"])
165
228
  _active_chats: Dict[str, asyncio.Event] = {}
166
229
  _chat_lock = asyncio.Lock()
230
+ _turn_lock = asyncio.Lock()
231
+ _current_by_target: Dict[str, Dict[str, Any]] = {}
232
+ _current_by_client: Dict[str, Dict[str, Any]] = {}
233
+ _last_by_client: Dict[str, Dict[str, Any]] = {}
167
234
 
168
235
  async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
169
236
  async with _chat_lock:
@@ -175,6 +242,61 @@ def build_file_chat_routes() -> APIRouter:
175
242
  async with _chat_lock:
176
243
  _active_chats.pop(key, None)
177
244
 
245
+ async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
246
+ async with _turn_lock:
247
+ state: Dict[str, Any] = {
248
+ "client_turn_id": client_turn_id or "",
249
+ "target": target.target,
250
+ "status": "starting",
251
+ "agent": None,
252
+ "thread_id": None,
253
+ "turn_id": None,
254
+ }
255
+ _current_by_target[target.state_key] = state
256
+ if client_turn_id:
257
+ _current_by_client[client_turn_id] = state
258
+
259
+ async def _update_turn_state(target: _Target, **updates: Any) -> None:
260
+ async with _turn_lock:
261
+ state = _current_by_target.get(target.state_key)
262
+ if not state:
263
+ return
264
+ for key, value in updates.items():
265
+ if value is None:
266
+ continue
267
+ state[key] = value
268
+ cid = state.get("client_turn_id") or ""
269
+ if cid:
270
+ _current_by_client[cid] = state
271
+
272
+ async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
273
+ async with _turn_lock:
274
+ state = _current_by_target.pop(target.state_key, None)
275
+ cid = ""
276
+ if state:
277
+ cid = state.get("client_turn_id", "") or ""
278
+ if cid:
279
+ _current_by_client.pop(cid, None)
280
+ _last_by_client[cid] = dict(result or {})
281
+
282
+ async def _active_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
283
+ if not client_turn_id:
284
+ return {}
285
+ async with _turn_lock:
286
+ return dict(_current_by_client.get(client_turn_id, {}))
287
+
288
+ async def _last_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
289
+ if not client_turn_id:
290
+ return {}
291
+ async with _turn_lock:
292
+ return dict(_last_by_client.get(client_turn_id, {}))
293
+
294
+ @router.get("/file-chat/active")
295
+ async def file_chat_active(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
296
+ current = await _active_for_client(client_turn_id)
297
+ last = await _last_for_client(client_turn_id)
298
+ return {"active": bool(current), "current": current, "last_result": last}
299
+
178
300
  @router.post("/file-chat")
179
301
  async def chat_file(request: Request):
180
302
  """Chat with a file target - optionally streams SSE events."""
@@ -185,6 +307,7 @@ def build_file_chat_routes() -> APIRouter:
185
307
  agent = body.get("agent", "codex")
186
308
  model = body.get("model")
187
309
  reasoning = body.get("reasoning")
310
+ client_turn_id = (body.get("client_turn_id") or "").strip() or None
188
311
 
189
312
  if not message:
190
313
  raise HTTPException(status_code=400, detail="message is required")
@@ -203,6 +326,8 @@ def build_file_chat_routes() -> APIRouter:
203
326
  raise HTTPException(status_code=409, detail="File chat already running")
204
327
  _active_chats[target.state_key] = asyncio.Event()
205
328
 
329
+ await _begin_turn_state(target, client_turn_id)
330
+
206
331
  if stream:
207
332
  return StreamingResponse(
208
333
  _stream_file_chat(
@@ -213,21 +338,47 @@ def build_file_chat_routes() -> APIRouter:
213
338
  agent=agent,
214
339
  model=model,
215
340
  reasoning=reasoning,
341
+ client_turn_id=client_turn_id,
216
342
  ),
217
343
  media_type="text/event-stream",
218
344
  headers=SSE_HEADERS,
219
345
  )
220
346
 
221
347
  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
- )
348
+ result: Dict[str, Any]
349
+
350
+ async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
351
+ await _update_turn_state(
352
+ target,
353
+ agent=agent_id,
354
+ thread_id=thread_id,
355
+ turn_id=turn_id,
356
+ )
357
+
358
+ try:
359
+ result = await _execute_file_chat(
360
+ request,
361
+ repo_root,
362
+ target,
363
+ message,
364
+ agent=agent,
365
+ model=model,
366
+ reasoning=reasoning,
367
+ on_meta=_on_meta,
368
+ )
369
+ except Exception as exc:
370
+ await _finalize_turn_state(
371
+ target,
372
+ {
373
+ "status": "error",
374
+ "detail": str(exc),
375
+ "client_turn_id": client_turn_id or "",
376
+ },
377
+ )
378
+ raise
379
+ result = dict(result or {})
380
+ result["client_turn_id"] = client_turn_id or ""
381
+ await _finalize_turn_state(target, result)
231
382
  return result
232
383
  finally:
233
384
  await _clear_interrupt_event(target.state_key)
@@ -241,22 +392,62 @@ def build_file_chat_routes() -> APIRouter:
241
392
  agent: str = "codex",
242
393
  model: Optional[str] = None,
243
394
  reasoning: Optional[str] = None,
395
+ client_turn_id: Optional[str] = None,
244
396
  ) -> AsyncIterator[str]:
245
397
  yield format_sse("status", {"status": "queued"})
246
398
  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,
399
+
400
+ async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
401
+ await _update_turn_state(
402
+ target,
403
+ agent=agent_id,
404
+ thread_id=thread_id,
405
+ turn_id=turn_id,
406
+ )
407
+
408
+ run_task = asyncio.create_task(
409
+ _execute_file_chat(
410
+ request,
411
+ repo_root,
412
+ target,
413
+ message,
414
+ agent=agent,
415
+ model=model,
416
+ reasoning=reasoning,
417
+ on_meta=_on_meta,
418
+ )
255
419
  )
420
+
421
+ async def _finalize() -> None:
422
+ result = {"status": "error", "detail": "File chat failed"}
423
+ try:
424
+ result = await run_task
425
+ except Exception as exc:
426
+ logger.exception("file chat task failed")
427
+ result = {
428
+ "status": "error",
429
+ "detail": str(exc) or "File chat failed",
430
+ }
431
+ result = dict(result or {})
432
+ result["client_turn_id"] = client_turn_id or ""
433
+ await _finalize_turn_state(target, result)
434
+
435
+ asyncio.create_task(_finalize())
436
+
437
+ try:
438
+ result = await asyncio.shield(run_task)
439
+ except asyncio.CancelledError:
440
+ # client disconnected; turn continues in background
441
+ return
442
+
256
443
  if result.get("status") == "ok":
257
444
  raw_events = result.pop("raw_events", []) or []
258
445
  for event in raw_events:
259
446
  yield format_sse("app-server", event)
447
+ usage_parts = result.pop("usage_parts", []) or []
448
+ for usage in usage_parts:
449
+ yield format_sse("token_usage", usage)
450
+ result["client_turn_id"] = client_turn_id or ""
260
451
  yield format_sse("update", result)
261
452
  yield format_sse("done", {"status": "ok"})
262
453
  elif result.get("status") == "interrupted":
@@ -283,11 +474,14 @@ def build_file_chat_routes() -> APIRouter:
283
474
  agent: str = "codex",
284
475
  model: Optional[str] = None,
285
476
  reasoning: Optional[str] = None,
477
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
478
+ on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
286
479
  ) -> Dict[str, Any]:
287
480
  supervisor = getattr(request.app.state, "app_server_supervisor", None)
288
481
  threads = getattr(request.app.state, "app_server_threads", None)
289
482
  opencode = getattr(request.app.state, "opencode_supervisor", None)
290
483
  engine = getattr(request.app.state, "engine", None)
484
+ events = getattr(request.app.state, "app_server_events", None)
291
485
  stall_timeout_seconds = None
292
486
  try:
293
487
  stall_timeout_seconds = (
@@ -303,21 +497,7 @@ def build_file_chat_routes() -> APIRouter:
303
497
  before = _read_file(target.path)
304
498
  base_hash = _hash_content(before)
305
499
 
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
- )
500
+ prompt = _build_file_chat_prompt(target=target, message=message, before=before)
321
501
 
322
502
  interrupt_event = await _get_or_create_interrupt_event(target.state_key)
323
503
  if interrupt_event.is_set():
@@ -329,6 +509,7 @@ def build_file_chat_routes() -> APIRouter:
329
509
  agent_id = "codex"
330
510
 
331
511
  thread_key = f"file_chat.{target.state_key}"
512
+ await _update_turn_state(target, status="running", agent=agent_id)
332
513
 
333
514
  if agent_id == "opencode":
334
515
  if opencode is None:
@@ -343,6 +524,8 @@ def build_file_chat_routes() -> APIRouter:
343
524
  thread_registry=threads,
344
525
  thread_key=thread_key,
345
526
  stall_timeout_seconds=stall_timeout_seconds,
527
+ on_meta=on_meta,
528
+ on_usage=on_usage,
346
529
  )
347
530
  else:
348
531
  if supervisor is None:
@@ -355,10 +538,13 @@ def build_file_chat_routes() -> APIRouter:
355
538
  repo_root,
356
539
  prompt,
357
540
  interrupt_event,
541
+ agent_id=agent_id,
358
542
  model=model,
359
543
  reasoning=reasoning,
360
544
  thread_registry=threads,
361
545
  thread_key=thread_key,
546
+ on_meta=on_meta,
547
+ events=events,
362
548
  )
363
549
 
364
550
  if result.get("status") != "ok":
@@ -393,6 +579,7 @@ def build_file_chat_routes() -> APIRouter:
393
579
  return {
394
580
  "status": "ok",
395
581
  "target": target.target,
582
+ "agent": agent_id,
396
583
  "agent_message": agent_message,
397
584
  "message": response_text,
398
585
  "has_draft": True,
@@ -400,6 +587,8 @@ def build_file_chat_routes() -> APIRouter:
400
587
  "content": after,
401
588
  "base_hash": base_hash,
402
589
  "created_at": drafts[target.state_key]["created_at"],
590
+ "thread_id": result.get("thread_id"),
591
+ "turn_id": result.get("turn_id"),
403
592
  **(
404
593
  {"raw_events": result.get("raw_events")}
405
594
  if result.get("raw_events")
@@ -410,9 +599,12 @@ def build_file_chat_routes() -> APIRouter:
410
599
  return {
411
600
  "status": "ok",
412
601
  "target": target.target,
602
+ "agent": agent_id,
413
603
  "agent_message": agent_message,
414
604
  "message": response_text,
415
605
  "has_draft": False,
606
+ "thread_id": result.get("thread_id"),
607
+ "turn_id": result.get("turn_id"),
416
608
  **(
417
609
  {"raw_events": result.get("raw_events")}
418
610
  if result.get("raw_events")
@@ -428,8 +620,11 @@ def build_file_chat_routes() -> APIRouter:
428
620
  *,
429
621
  model: Optional[str] = None,
430
622
  reasoning: Optional[str] = None,
623
+ agent_id: str = "codex",
431
624
  thread_registry: Optional[Any] = None,
432
625
  thread_key: Optional[str] = None,
626
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
627
+ events: Optional[Any] = None,
433
628
  ) -> Dict[str, Any]:
434
629
  client = await supervisor.get_client(repo_root)
435
630
 
@@ -463,6 +658,18 @@ def build_file_chat_routes() -> APIRouter:
463
658
  sandbox_policy="dangerFullAccess",
464
659
  **turn_kwargs,
465
660
  )
661
+ if events is not None:
662
+ try:
663
+ await events.register_turn(thread_id, handle.turn_id)
664
+ except Exception:
665
+ logger.debug("file chat register_turn failed", exc_info=True)
666
+ if on_meta is not None:
667
+ try:
668
+ maybe = on_meta(agent_id, thread_id, handle.turn_id)
669
+ if asyncio.iscoroutine(maybe):
670
+ await maybe
671
+ except Exception:
672
+ logger.debug("file chat meta callback failed", exc_info=True)
466
673
 
467
674
  turn_task = asyncio.create_task(handle.wait(timeout=None))
468
675
  timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
@@ -495,6 +702,9 @@ def build_file_chat_routes() -> APIRouter:
495
702
  "agent_message": agent_message,
496
703
  "message": output,
497
704
  "raw_events": raw_events,
705
+ "thread_id": thread_id,
706
+ "turn_id": getattr(handle, "turn_id", None),
707
+ "agent": agent_id,
498
708
  }
499
709
 
500
710
  async def _execute_opencode(
@@ -508,9 +718,12 @@ def build_file_chat_routes() -> APIRouter:
508
718
  thread_registry: Optional[Any] = None,
509
719
  thread_key: Optional[str] = None,
510
720
  stall_timeout_seconds: Optional[float] = None,
721
+ on_meta: Optional[Callable[[str, str, str], Any]] = None,
722
+ on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
511
723
  ) -> Dict[str, Any]:
512
724
  from ....agents.opencode.runtime import (
513
725
  PERMISSION_ALLOW,
726
+ build_turn_id,
514
727
  collect_opencode_output,
515
728
  extract_session_id,
516
729
  parse_message_response,
@@ -529,9 +742,32 @@ def build_file_chat_routes() -> APIRouter:
529
742
  if thread_registry is not None and thread_key:
530
743
  thread_registry.set_thread_id(thread_key, session_id)
531
744
 
745
+ turn_id = build_turn_id(session_id)
746
+ if on_meta is not None:
747
+ try:
748
+ maybe = on_meta("opencode", session_id, turn_id)
749
+ if asyncio.iscoroutine(maybe):
750
+ await maybe
751
+ except Exception:
752
+ logger.debug("file chat opencode meta failed", exc_info=True)
753
+
532
754
  model_payload = split_model_id(model)
533
755
  await supervisor.mark_turn_started(repo_root)
534
756
 
757
+ usage_parts: list[Dict[str, Any]] = []
758
+
759
+ async def _part_handler(
760
+ part_type: str, part: Any, turn_id_arg: Optional[str] | None
761
+ ) -> None:
762
+ if part_type == "usage" and on_usage is not None:
763
+ usage_parts.append(part)
764
+ try:
765
+ maybe = on_usage(part)
766
+ if asyncio.iscoroutine(maybe):
767
+ await maybe
768
+ except Exception:
769
+ logger.debug("file chat usage handler failed", exc_info=True)
770
+
535
771
  ready_event = asyncio.Event()
536
772
  output_task = asyncio.create_task(
537
773
  collect_opencode_output(
@@ -543,6 +779,7 @@ def build_file_chat_routes() -> APIRouter:
543
779
  question_policy="auto_first_option",
544
780
  should_stop=interrupt_event.is_set,
545
781
  ready_event=ready_event,
782
+ part_handler=_part_handler,
546
783
  stall_timeout_seconds=stall_timeout_seconds,
547
784
  )
548
785
  )
@@ -593,11 +830,17 @@ def build_file_chat_routes() -> APIRouter:
593
830
  if output_result.error:
594
831
  raise FileChatError(output_result.error)
595
832
  agent_message = _parse_agent_message(output_result.text)
596
- return {
833
+ result = {
597
834
  "status": "ok",
598
835
  "agent_message": agent_message,
599
836
  "message": output_result.text,
837
+ "thread_id": session_id,
838
+ "turn_id": turn_id,
839
+ "agent": "opencode",
600
840
  }
841
+ if usage_parts:
842
+ result["usage_parts"] = usage_parts
843
+ return result
601
844
 
602
845
  def _parse_agent_message(output: str) -> str:
603
846
  text = (output or "").strip()
@@ -693,6 +936,37 @@ def build_file_chat_routes() -> APIRouter:
693
936
  "content": _read_file(resolved.path),
694
937
  }
695
938
 
939
+ @router.get("/file-chat/turns/{turn_id}/events")
940
+ async def stream_file_chat_turn_events(
941
+ turn_id: str, request: Request, thread_id: str, agent: str = "codex"
942
+ ):
943
+ agent_id = (agent or "").strip().lower()
944
+ if agent_id == "codex":
945
+ events = getattr(request.app.state, "app_server_events", None)
946
+ if events is None:
947
+ raise HTTPException(status_code=404, detail="Events unavailable")
948
+ if not thread_id:
949
+ raise HTTPException(status_code=400, detail="thread_id is required")
950
+ return StreamingResponse(
951
+ events.stream(thread_id, turn_id),
952
+ media_type="text/event-stream",
953
+ headers=SSE_HEADERS,
954
+ )
955
+ if agent_id == "opencode":
956
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
957
+ if supervisor is None:
958
+ raise HTTPException(status_code=404, detail="OpenCode unavailable")
959
+ from ....agents.opencode.harness import OpenCodeHarness
960
+
961
+ harness = OpenCodeHarness(supervisor)
962
+ repo_root = _resolve_repo_root(request)
963
+ return StreamingResponse(
964
+ harness.stream_events(repo_root, thread_id, turn_id),
965
+ media_type="text/event-stream",
966
+ headers=SSE_HEADERS,
967
+ )
968
+ raise HTTPException(status_code=404, detail="Unknown agent")
969
+
696
970
  @router.post("/file-chat/interrupt")
697
971
  async def interrupt_file_chat(request: Request):
698
972
  body = await request.json()
@@ -715,6 +989,7 @@ def build_file_chat_routes() -> APIRouter:
715
989
  agent = body.get("agent", "codex")
716
990
  model = body.get("model")
717
991
  reasoning = body.get("reasoning")
992
+ client_turn_id = (body.get("client_turn_id") or "").strip() or None
718
993
 
719
994
  if not message:
720
995
  raise HTTPException(status_code=400, detail="message is required")
@@ -729,6 +1004,7 @@ def build_file_chat_routes() -> APIRouter:
729
1004
  status_code=409, detail="Ticket chat already running"
730
1005
  )
731
1006
  _active_chats[target.state_key] = asyncio.Event()
1007
+ await _begin_turn_state(target, client_turn_id)
732
1008
 
733
1009
  if stream:
734
1010
  return StreamingResponse(
@@ -740,13 +1016,14 @@ def build_file_chat_routes() -> APIRouter:
740
1016
  agent=agent,
741
1017
  model=model,
742
1018
  reasoning=reasoning,
1019
+ client_turn_id=client_turn_id,
743
1020
  ),
744
1021
  media_type="text/event-stream",
745
1022
  headers=SSE_HEADERS,
746
1023
  )
747
1024
 
748
1025
  try:
749
- return await _execute_file_chat(
1026
+ result = await _execute_file_chat(
750
1027
  request,
751
1028
  repo_root,
752
1029
  target,
@@ -755,6 +1032,10 @@ def build_file_chat_routes() -> APIRouter:
755
1032
  model=model,
756
1033
  reasoning=reasoning,
757
1034
  )
1035
+ result = dict(result or {})
1036
+ result["client_turn_id"] = client_turn_id or ""
1037
+ await _finalize_turn_state(target, result)
1038
+ return result
758
1039
  finally:
759
1040
  await _clear_interrupt_event(target.state_key)
760
1041