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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,8 @@ from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
  from typing import Any, Callable, Optional, cast
7
7
 
8
- from ..agents.opencode.runtime import collect_opencode_output
8
+ from ..agents.opencode.constants import DEFAULT_TICKET_MODEL
9
+ from ..agents.opencode.runtime import collect_opencode_output, split_model_id
9
10
  from ..agents.opencode.supervisor import OpenCodeSupervisor
10
11
  from ..core.config import RepoConfig
11
12
  from ..core.flows.models import FlowEventType
@@ -29,6 +30,10 @@ class AgentTurnRequest:
29
30
  options: Optional[dict[str, Any]] = None
30
31
  # Optional flow event emitter (for live streaming).
31
32
  emit_event: Optional[EmitEventFn] = None
33
+ # Optional list of additional messages to send in the same turn.
34
+ # Each message is a dict with a "text" field. Agents that support
35
+ # multiple messages will receive all of them; others may queue them.
36
+ additional_messages: Optional[list[dict[str, Any]]] = None
32
37
 
33
38
 
34
39
  @dataclass(frozen=True)
@@ -178,6 +183,7 @@ class AgentPool:
178
183
  max_handles=app_server_cfg.max_handles,
179
184
  idle_ttl_seconds=app_server_cfg.idle_ttl_seconds,
180
185
  session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
186
+ max_text_chars=self._config.opencode.max_text_chars,
181
187
  base_env=None,
182
188
  subagent_models=subagent_models,
183
189
  )
@@ -185,8 +191,8 @@ class AgentPool:
185
191
  raise RuntimeError(
186
192
  "OpenCode supervisor unavailable (missing opencode command/binary)."
187
193
  )
188
- self._opencode_supervisor = supervisor
189
- return supervisor
194
+ self._opencode_supervisor = cast(OpenCodeSupervisor, supervisor)
195
+ return self._opencode_supervisor
190
196
 
191
197
  async def close(self) -> None:
192
198
  if self._app_server_supervisor is not None:
@@ -248,6 +254,19 @@ class AgentPool:
248
254
  turn_kwargs["model"] = req.options["model"]
249
255
  if req.options.get("reasoning"):
250
256
  turn_kwargs["effort"] = req.options["reasoning"]
257
+
258
+ # Build input items - main prompt plus any additional messages
259
+ input_items: Optional[list[dict[str, Any]]] = None
260
+ if req.additional_messages:
261
+ input_items = [{"type": "text", "text": req.prompt}]
262
+ for msg in req.additional_messages:
263
+ if isinstance(msg, dict):
264
+ text = msg.get("text", "")
265
+ if text and text.strip():
266
+ input_items.append({"type": "text", "text": text})
267
+ if input_items:
268
+ turn_kwargs["input_items"] = input_items
269
+
251
270
  turn_handle = await client.turn_start(thread_id, req.prompt, **turn_kwargs)
252
271
  if req.emit_event is not None:
253
272
  self._active_emitters[turn_handle.turn_id] = req.emit_event
@@ -274,6 +293,24 @@ class AgentPool:
274
293
  client = handle
275
294
  directory = str(req.workspace_root)
276
295
 
296
+ options = req.options if isinstance(req.options, dict) else {}
297
+ model_raw = options.get("model")
298
+ model_payload = None
299
+ if isinstance(model_raw, dict):
300
+ provider_id = model_raw.get("providerID") or model_raw.get("providerId")
301
+ model_id = model_raw.get("modelID") or model_raw.get("modelId")
302
+ if provider_id and model_id:
303
+ model_payload = {"providerID": provider_id, "modelID": model_id}
304
+ elif isinstance(model_raw, str) and model_raw.strip():
305
+ model_payload = split_model_id(model_raw.strip())
306
+ if model_payload is None:
307
+ model_payload = split_model_id(DEFAULT_TICKET_MODEL)
308
+
309
+ variant = None
310
+ reasoning_raw = options.get("reasoning")
311
+ if isinstance(reasoning_raw, str) and reasoning_raw.strip():
312
+ variant = reasoning_raw.strip()
313
+
277
314
  session_id = req.conversation_id
278
315
  if not session_id:
279
316
  created = await client.create_session(title="ticket", directory=directory)
@@ -281,7 +318,18 @@ class AgentPool:
281
318
  if not session_id:
282
319
  raise RuntimeError("OpenCode create_session returned no session id")
283
320
 
284
- prompt_response = await client.prompt_async(session_id, message=req.prompt)
321
+ # Send main prompt and any additional messages
322
+ # OpenCode processes messages sequentially; agents that queue will handle them
323
+ prompt_response = await client.prompt_async(
324
+ session_id, message=req.prompt, model=model_payload, variant=variant
325
+ )
326
+ if req.additional_messages:
327
+ for msg in req.additional_messages:
328
+ text = msg.get("text", "") if isinstance(msg, dict) else ""
329
+ if text and text.strip():
330
+ await client.prompt_async(
331
+ session_id, message=text, model=model_payload, variant=variant
332
+ )
285
333
 
286
334
  import uuid
287
335
 
@@ -340,6 +388,7 @@ class AgentPool:
340
388
  client,
341
389
  session_id=session_id,
342
390
  workspace_path=directory,
391
+ model_payload=model_payload,
343
392
  part_handler=_part_handler if req.emit_event is not None else None,
344
393
  )
345
394
 
@@ -1,25 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
- from pathlib import Path
3
+ from pathlib import Path, PurePosixPath
5
4
  from typing import Optional
6
5
 
7
6
  from .frontmatter import parse_markdown_frontmatter
8
- from .lint import lint_ticket_frontmatter
7
+ from .lint import lint_ticket_frontmatter, parse_ticket_index
9
8
  from .models import TicketDoc, TicketFrontmatter
10
9
 
11
- _TICKET_NAME_RE = re.compile(r"^TICKET-(\d+)\.md$", re.IGNORECASE)
12
-
13
-
14
- def parse_ticket_index(name: str) -> Optional[int]:
15
- match = _TICKET_NAME_RE.match(name)
16
- if not match:
17
- return None
18
- try:
19
- return int(match.group(1))
20
- except ValueError:
21
- return None
22
-
23
10
 
24
11
  def list_ticket_paths(ticket_dir: Path) -> list[Path]:
25
12
  if not ticket_dir.exists() or not ticket_dir.is_dir():
@@ -51,11 +38,14 @@ def read_ticket(path: Path) -> tuple[Optional[TicketDoc], list[str]]:
51
38
  data, body = parse_markdown_frontmatter(raw)
52
39
  idx = parse_ticket_index(path.name)
53
40
  if idx is None:
54
- return None, ["Invalid ticket filename; expected TICKET-<number>.md"]
41
+ return None, [
42
+ "Invalid ticket filename; expected TICKET-<number>[suffix].md (e.g. TICKET-001-foo.md)"
43
+ ]
55
44
 
56
45
  frontmatter, errors = lint_ticket_frontmatter(data)
57
46
  if errors:
58
47
  return None, errors
48
+ assert frontmatter is not None
59
49
  return TicketDoc(path=path, index=idx, frontmatter=frontmatter, body=body), []
60
50
 
61
51
 
@@ -83,3 +73,34 @@ def safe_relpath(path: Path, root: Path) -> str:
83
73
  return str(path.relative_to(root))
84
74
  except ValueError:
85
75
  return str(path)
76
+
77
+
78
+ def normalize_ticket_dir(repo_root: Path, ticket_dir: Optional[str]) -> Path:
79
+ """Normalize a user-supplied ticket directory and ensure it stays in-tree."""
80
+
81
+ base = (repo_root / ".codex-autorunner").resolve(strict=False)
82
+ if not ticket_dir:
83
+ return base / "tickets"
84
+
85
+ cleaned = str(ticket_dir).strip()
86
+ if not cleaned:
87
+ return base / "tickets"
88
+ if "\\" in cleaned:
89
+ raise ValueError("Ticket directory may not include backslashes.")
90
+
91
+ raw_path = Path(cleaned)
92
+ if raw_path.is_absolute():
93
+ candidate = raw_path.resolve(strict=False)
94
+ else:
95
+ relative = PurePosixPath(cleaned)
96
+ if relative.is_absolute() or ".." in relative.parts:
97
+ raise ValueError("Ticket directory must be a relative path.")
98
+ candidate = (repo_root / relative).resolve(strict=False)
99
+
100
+ try:
101
+ candidate.relative_to(base)
102
+ except ValueError:
103
+ raise ValueError(
104
+ "Ticket directory must live under .codex-autorunner."
105
+ ) from None
106
+ return candidate
@@ -1,10 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
4
+ from collections import defaultdict
5
+ from pathlib import Path
3
6
  from typing import Any, Optional, Tuple
4
7
 
5
8
  from ..agents.registry import validate_agent_id
6
9
  from .models import TicketFrontmatter
7
10
 
11
+ # Accept TICKET-###.md or TICKET-###<suffix>.md (suffix optional), case-insensitive.
12
+ _TICKET_NAME_RE = re.compile(r"^TICKET-(\d{3,})(?:[^/]*)\.md$", re.IGNORECASE)
13
+
14
+
15
+ def parse_ticket_index(name: str) -> Optional[int]:
16
+ match = _TICKET_NAME_RE.match(name)
17
+ if not match:
18
+ return None
19
+ try:
20
+ return int(match.group(1))
21
+ except ValueError:
22
+ return None
23
+
8
24
 
9
25
  def _as_optional_str(value: Any) -> Optional[str]:
10
26
  if isinstance(value, str):
@@ -100,3 +116,37 @@ def lint_dispatch_frontmatter(
100
116
  normalized = dict(data)
101
117
  normalized["mode"] = mode
102
118
  return normalized, errors
119
+
120
+
121
+ def lint_ticket_directory(ticket_dir: Path) -> list[str]:
122
+ """Validate ticket directory for duplicate indices.
123
+
124
+ Returns a list of error messages (empty if valid).
125
+
126
+ This check ensures that ticket indices are unique across all ticket files.
127
+ Duplicate indices lead to non-deterministic ordering and confusing behavior.
128
+ """
129
+
130
+ if not ticket_dir.exists() or not ticket_dir.is_dir():
131
+ return []
132
+
133
+ errors: list[str] = []
134
+ index_to_paths: dict[int, list[str]] = defaultdict(list)
135
+
136
+ for path in ticket_dir.iterdir():
137
+ if not path.is_file():
138
+ continue
139
+ idx = parse_ticket_index(path.name)
140
+ if idx is None:
141
+ continue
142
+ index_to_paths[idx].append(path.name)
143
+
144
+ for idx, filenames in index_to_paths.items():
145
+ if len(filenames) > 1:
146
+ filenames_str = ", ".join([f"'{f}'" for f in filenames])
147
+ errors.append(
148
+ f"Duplicate ticket index {idx:03d}: multiple files share the same index ({filenames_str}). "
149
+ "Rename or remove duplicates to ensure deterministic ordering."
150
+ )
151
+
152
+ return errors
@@ -4,6 +4,8 @@ from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
5
  from typing import Any, Optional
6
6
 
7
+ DEFAULT_MAX_TOTAL_TURNS = 50
8
+
7
9
 
8
10
  @dataclass(frozen=True)
9
11
  class TicketFrontmatter:
@@ -70,13 +72,16 @@ class DispatchRecord:
70
72
  class TicketRunConfig:
71
73
  ticket_dir: Path
72
74
  runs_dir: Path
73
- max_total_turns: int = 25
75
+ max_total_turns: int = DEFAULT_MAX_TOTAL_TURNS
74
76
  max_lint_retries: int = 3
75
77
  max_commit_retries: int = 2
78
+ max_network_retries: int = 5
76
79
  auto_commit: bool = True
80
+ prompt_max_bytes: int = 5 * 1024 * 1024 # 5 MB default budget
77
81
  checkpoint_message_template: str = (
78
82
  "CAR checkpoint: run={run_id} turn={turn} agent={agent}"
79
83
  )
84
+ include_previous_ticket_context: bool = False
80
85
 
81
86
 
82
87
  @dataclass(frozen=True)
@@ -3,12 +3,31 @@ from __future__ import annotations
3
3
  import shutil
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
- from typing import Optional
6
+ from typing import Any, Callable, Dict, Optional
7
7
 
8
8
  from .frontmatter import parse_markdown_frontmatter
9
9
  from .lint import lint_dispatch_frontmatter
10
10
  from .models import Dispatch, DispatchRecord
11
11
 
12
+ _lifecycle_emitter: Optional[Callable[[str, str, str, Dict[str, Any]], None]] = None
13
+
14
+
15
+ def set_lifecycle_emitter(
16
+ emitter: Optional[Callable[[str, str, str, Dict[str, Any]], None]],
17
+ ) -> None:
18
+ global _lifecycle_emitter
19
+ _lifecycle_emitter = emitter
20
+
21
+
22
+ def _emit_lifecycle(
23
+ event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
24
+ ) -> None:
25
+ if _lifecycle_emitter:
26
+ try:
27
+ _lifecycle_emitter(event_type, repo_id, run_id, data)
28
+ except Exception:
29
+ pass
30
+
12
31
 
13
32
  @dataclass(frozen=True)
14
33
  class OutboxPaths:
@@ -103,12 +122,23 @@ def create_turn_summary(
103
122
  ticket_id: Optional[str] = None,
104
123
  agent_id: Optional[str] = None,
105
124
  turn_number: Optional[int] = None,
125
+ diff_stats: Optional[dict] = None,
106
126
  ) -> tuple[Optional[DispatchRecord], list[str]]:
107
127
  """Create a turn summary dispatch record for the agent's final output.
108
128
 
109
129
  This creates a synthetic dispatch with mode="turn_summary" to show
110
130
  the agent's final turn output in the dispatch history panel.
111
131
 
132
+ Args:
133
+ paths: Outbox paths for the run
134
+ next_seq: Sequence number for this dispatch
135
+ agent_output: The agent's output text
136
+ ticket_id: Optional ticket ID for context
137
+ agent_id: Optional agent ID (e.g., "codex", "opencode")
138
+ turn_number: Optional turn number
139
+ diff_stats: Optional dict with insertions/deletions/files_changed.
140
+ Deprecated: diff stats are now stored as FlowStore DIFF_UPDATED events.
141
+
112
142
  Returns (DispatchRecord, []) on success.
113
143
  Returns (None, errors) on failure.
114
144
  """
@@ -123,6 +153,8 @@ def create_turn_summary(
123
153
  extra["agent_id"] = agent_id
124
154
  if turn_number is not None:
125
155
  extra["turn_number"] = turn_number
156
+ # NOTE: diff_stats is intentionally not persisted into DISPATCH.md frontmatter.
157
+ # It is stored as structured FlowStore DIFF_UPDATED events instead.
126
158
  extra["is_turn_summary"] = True
127
159
 
128
160
  dispatch = Dispatch(
@@ -163,8 +195,10 @@ def archive_dispatch(
163
195
  *,
164
196
  next_seq: int,
165
197
  ticket_id: Optional[str] = None,
198
+ repo_id: str = "",
199
+ run_id: str = "",
166
200
  ) -> tuple[Optional[DispatchRecord], list[str]]:
167
- """Archive the current dispatch and attachments to the dispatch history.
201
+ """Archive current dispatch and attachments to dispatch history.
168
202
 
169
203
  Moves DISPATCH.md + attachments into dispatch_history/<seq>/.
170
204
 
@@ -221,6 +255,20 @@ def archive_dispatch(
221
255
  pass
222
256
  _delete_dispatch_items(items)
223
257
 
258
+ # Emit lifecycle event for dispatch creation
259
+ if run_id:
260
+ _emit_lifecycle(
261
+ "dispatch_created",
262
+ repo_id,
263
+ run_id,
264
+ {
265
+ "seq": next_seq,
266
+ "mode": dispatch.mode,
267
+ "title": dispatch.title,
268
+ "ticket_id": ticket_id,
269
+ },
270
+ )
271
+
224
272
  return (
225
273
  DispatchRecord(
226
274
  seq=next_seq,