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,97 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ DEFAULT_MAX_TOTAL_TURNS = 50
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class TicketFrontmatter:
12
+ """Parsed, validated ticket frontmatter.
13
+
14
+ Only a minimal set of keys are required for orchestration. Additional
15
+ keys are preserved in `extra` for forward compatibility.
16
+ """
17
+
18
+ agent: str
19
+ done: bool
20
+ title: Optional[str] = None
21
+ goal: Optional[str] = None
22
+ # Optional model/reasoning overrides for this ticket.
23
+ model: Optional[str] = None
24
+ reasoning: Optional[str] = None
25
+ extra: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TicketDoc:
30
+ path: Path
31
+ index: int
32
+ frontmatter: TicketFrontmatter
33
+ body: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Dispatch:
38
+ """Agent-to-human communication dispatched via the outbox.
39
+
40
+ A Dispatch is the canonical unit of agent→human communication. The mode
41
+ determines whether it's informational or requires human action:
42
+ - "notify": FYI, agent continues working
43
+ - "pause": Handoff, agent yields and awaits human reply
44
+ """
45
+
46
+ mode: str # "notify" | "pause"
47
+ body: str
48
+ title: Optional[str] = None
49
+ extra: dict[str, Any] = field(default_factory=dict)
50
+
51
+ @property
52
+ def is_handoff(self) -> bool:
53
+ """True if this dispatch requires human action (mode='pause')."""
54
+ return self.mode == "pause"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DispatchRecord:
59
+ """Archived dispatch with sequence number and file references.
60
+
61
+ This is the envelope/record created when a Dispatch is archived to the
62
+ dispatch history directory.
63
+ """
64
+
65
+ seq: int
66
+ dispatch: Dispatch
67
+ archived_dir: Path
68
+ archived_files: tuple[Path, ...]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class TicketRunConfig:
73
+ ticket_dir: Path
74
+ runs_dir: Path
75
+ max_total_turns: int = DEFAULT_MAX_TOTAL_TURNS
76
+ max_lint_retries: int = 3
77
+ max_commit_retries: int = 2
78
+ auto_commit: bool = True
79
+ checkpoint_message_template: str = (
80
+ "CAR checkpoint: run={run_id} turn={turn} agent={agent}"
81
+ )
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class TicketResult:
86
+ """Return value of a single TicketRunner.step() call."""
87
+
88
+ status: str # "continue" | "paused" | "completed" | "failed"
89
+ state: dict[str, Any]
90
+ reason: Optional[str] = None
91
+ reason_details: Optional[str] = None # Technical details (git status, etc.)
92
+ dispatch: Optional[DispatchRecord] = None
93
+ current_ticket: Optional[str] = None
94
+ agent_output: Optional[str] = None
95
+ agent_id: Optional[str] = None
96
+ agent_conversation_id: Optional[str] = None
97
+ agent_turn_id: Optional[str] = None
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .frontmatter import parse_markdown_frontmatter
9
+ from .lint import lint_dispatch_frontmatter
10
+ from .models import Dispatch, DispatchRecord
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class OutboxPaths:
15
+ """Filesystem paths for the dispatch outbox."""
16
+
17
+ run_dir: Path
18
+ dispatch_dir: Path
19
+ dispatch_history_dir: Path
20
+ dispatch_path: Path
21
+
22
+
23
+ def resolve_outbox_paths(
24
+ *, workspace_root: Path, runs_dir: Path, run_id: str
25
+ ) -> OutboxPaths:
26
+ run_dir = workspace_root / runs_dir / run_id
27
+ dispatch_dir = run_dir / "dispatch"
28
+ dispatch_history_dir = run_dir / "dispatch_history"
29
+ dispatch_path = run_dir / "DISPATCH.md"
30
+ return OutboxPaths(
31
+ run_dir=run_dir,
32
+ dispatch_dir=dispatch_dir,
33
+ dispatch_history_dir=dispatch_history_dir,
34
+ dispatch_path=dispatch_path,
35
+ )
36
+
37
+
38
+ def ensure_outbox_dirs(paths: OutboxPaths) -> None:
39
+ paths.dispatch_dir.mkdir(parents=True, exist_ok=True)
40
+ paths.dispatch_history_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+
43
+ def _copy_item(src: Path, dst: Path) -> None:
44
+ if src.is_dir():
45
+ shutil.copytree(src, dst)
46
+ else:
47
+ dst.parent.mkdir(parents=True, exist_ok=True)
48
+ shutil.copy2(src, dst)
49
+
50
+
51
+ def _list_dispatch_items(dispatch_dir: Path) -> list[Path]:
52
+ if not dispatch_dir.exists() or not dispatch_dir.is_dir():
53
+ return []
54
+ items: list[Path] = []
55
+ for child in sorted(dispatch_dir.iterdir(), key=lambda p: p.name):
56
+ if child.name.startswith("."):
57
+ continue
58
+ items.append(child)
59
+ return items
60
+
61
+
62
+ def _delete_dispatch_items(items: list[Path]) -> None:
63
+ for item in items:
64
+ try:
65
+ if item.is_dir():
66
+ shutil.rmtree(item)
67
+ else:
68
+ item.unlink()
69
+ except OSError:
70
+ # Best-effort cleanup.
71
+ continue
72
+
73
+
74
+ def parse_dispatch(path: Path) -> tuple[Optional[Dispatch], list[str]]:
75
+ """Parse a dispatch file (DISPATCH.md) into a Dispatch object."""
76
+ try:
77
+ raw = path.read_text(encoding="utf-8")
78
+ except OSError as exc:
79
+ return None, [f"Failed to read dispatch file: {exc}"]
80
+
81
+ data, body = parse_markdown_frontmatter(raw)
82
+ normalized, errors = lint_dispatch_frontmatter(data)
83
+ if errors:
84
+ return None, errors
85
+
86
+ mode = normalized.get("mode", "notify")
87
+ title = normalized.get("title")
88
+ title_str = title.strip() if isinstance(title, str) and title.strip() else None
89
+ extra = dict(normalized)
90
+ extra.pop("mode", None)
91
+ extra.pop("title", None)
92
+ return (
93
+ Dispatch(mode=mode, body=body.lstrip("\n"), title=title_str, extra=extra),
94
+ [],
95
+ )
96
+
97
+
98
+ def create_turn_summary(
99
+ paths: OutboxPaths,
100
+ *,
101
+ next_seq: int,
102
+ agent_output: str,
103
+ ticket_id: Optional[str] = None,
104
+ agent_id: Optional[str] = None,
105
+ turn_number: Optional[int] = None,
106
+ diff_stats: Optional[dict] = None,
107
+ ) -> tuple[Optional[DispatchRecord], list[str]]:
108
+ """Create a turn summary dispatch record for the agent's final output.
109
+
110
+ This creates a synthetic dispatch with mode="turn_summary" to show
111
+ the agent's final turn output in the dispatch history panel.
112
+
113
+ Args:
114
+ paths: Outbox paths for the run
115
+ next_seq: Sequence number for this dispatch
116
+ agent_output: The agent's output text
117
+ ticket_id: Optional ticket ID for context
118
+ agent_id: Optional agent ID (e.g., "codex", "opencode")
119
+ turn_number: Optional turn number
120
+ diff_stats: Optional dict with insertions/deletions/files_changed
121
+
122
+ Returns (DispatchRecord, []) on success.
123
+ Returns (None, errors) on failure.
124
+ """
125
+
126
+ if not agent_output or not agent_output.strip():
127
+ return None, []
128
+
129
+ extra: dict = {}
130
+ if ticket_id:
131
+ extra["ticket_id"] = ticket_id
132
+ if agent_id:
133
+ extra["agent_id"] = agent_id
134
+ if turn_number is not None:
135
+ extra["turn_number"] = turn_number
136
+ if diff_stats:
137
+ extra["diff_stats"] = diff_stats
138
+ extra["is_turn_summary"] = True
139
+
140
+ dispatch = Dispatch(
141
+ mode="turn_summary",
142
+ body=agent_output.strip(),
143
+ title=None,
144
+ extra=extra,
145
+ )
146
+
147
+ dest = paths.dispatch_history_dir / f"{next_seq:04d}"
148
+ try:
149
+ dest.mkdir(parents=True, exist_ok=False)
150
+ except OSError as exc:
151
+ return None, [f"Failed to create turn summary dir: {exc}"]
152
+
153
+ # Write a synthetic DISPATCH.md for consistency
154
+ msg_dest = dest / "DISPATCH.md"
155
+ try:
156
+ # Write minimal frontmatter + body
157
+ content = f"---\nmode: turn_summary\n---\n\n{agent_output.strip()}\n"
158
+ msg_dest.write_text(content, encoding="utf-8")
159
+ except OSError as exc:
160
+ return None, [f"Failed to write turn summary: {exc}"]
161
+
162
+ return (
163
+ DispatchRecord(
164
+ seq=next_seq,
165
+ dispatch=dispatch,
166
+ archived_dir=dest,
167
+ archived_files=(msg_dest,),
168
+ ),
169
+ [],
170
+ )
171
+
172
+
173
+ def archive_dispatch(
174
+ paths: OutboxPaths,
175
+ *,
176
+ next_seq: int,
177
+ ticket_id: Optional[str] = None,
178
+ ) -> tuple[Optional[DispatchRecord], list[str]]:
179
+ """Archive the current dispatch and attachments to the dispatch history.
180
+
181
+ Moves DISPATCH.md + attachments into dispatch_history/<seq>/.
182
+
183
+ Returns (DispatchRecord, []) on success.
184
+ Returns (None, []) when no dispatch file exists.
185
+ Returns (None, errors) on failure.
186
+ """
187
+
188
+ if not paths.dispatch_path.exists():
189
+ return None, []
190
+
191
+ dispatch, errors = parse_dispatch(paths.dispatch_path)
192
+ if errors or dispatch is None:
193
+ return None, errors
194
+
195
+ # Add ticket_id to extra if provided
196
+ if ticket_id and dispatch is not None:
197
+ extra = dict(dispatch.extra)
198
+ extra["ticket_id"] = ticket_id
199
+ dispatch = Dispatch(
200
+ mode=dispatch.mode,
201
+ body=dispatch.body,
202
+ title=dispatch.title,
203
+ extra=extra,
204
+ )
205
+
206
+ items = _list_dispatch_items(paths.dispatch_dir)
207
+ dest = paths.dispatch_history_dir / f"{next_seq:04d}"
208
+ try:
209
+ dest.mkdir(parents=True, exist_ok=False)
210
+ except OSError as exc:
211
+ return None, [f"Failed to create dispatch history dir: {exc}"]
212
+
213
+ archived: list[Path] = []
214
+ try:
215
+ # Archive the dispatch file.
216
+ msg_dest = dest / "DISPATCH.md"
217
+ _copy_item(paths.dispatch_path, msg_dest)
218
+ archived.append(msg_dest)
219
+
220
+ # Archive all attachments.
221
+ for item in items:
222
+ item_dest = dest / item.name
223
+ _copy_item(item, item_dest)
224
+ archived.append(item_dest)
225
+
226
+ except OSError as exc:
227
+ return None, [f"Failed to archive dispatch: {exc}"]
228
+
229
+ # Cleanup (best-effort).
230
+ try:
231
+ paths.dispatch_path.unlink()
232
+ except OSError:
233
+ pass
234
+ _delete_dispatch_items(items)
235
+
236
+ return (
237
+ DispatchRecord(
238
+ seq=next_seq,
239
+ dispatch=dispatch,
240
+ archived_dir=dest,
241
+ archived_files=tuple(archived),
242
+ ),
243
+ [],
244
+ )
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .frontmatter import parse_markdown_frontmatter
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ReplyPaths:
14
+ run_dir: Path
15
+ reply_dir: Path
16
+ reply_history_dir: Path
17
+ user_reply_path: Path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class UserReply:
22
+ body: str
23
+ title: Optional[str] = None
24
+ extra: dict = field(default_factory=dict)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ReplyDispatch:
29
+ seq: int
30
+ reply: UserReply
31
+ archived_dir: Path
32
+ archived_files: tuple[Path, ...]
33
+
34
+
35
+ def resolve_reply_paths(
36
+ *, workspace_root: Path, runs_dir: Path, run_id: str
37
+ ) -> ReplyPaths:
38
+ run_dir = workspace_root / runs_dir / run_id
39
+ reply_dir = run_dir / "reply"
40
+ reply_history_dir = run_dir / "reply_history"
41
+ user_reply_path = run_dir / "USER_REPLY.md"
42
+ return ReplyPaths(
43
+ run_dir=run_dir,
44
+ reply_dir=reply_dir,
45
+ reply_history_dir=reply_history_dir,
46
+ user_reply_path=user_reply_path,
47
+ )
48
+
49
+
50
+ def ensure_reply_dirs(paths: ReplyPaths) -> None:
51
+ paths.reply_dir.mkdir(parents=True, exist_ok=True)
52
+ paths.reply_history_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+
55
+ def parse_user_reply(path: Path) -> tuple[Optional[UserReply], list[str]]:
56
+ """Parse a USER_REPLY.md file.
57
+
58
+ USER_REPLY.md is intentionally permissive:
59
+ - frontmatter is optional
60
+ - we accept any YAML keys (stored in `extra`)
61
+ """
62
+
63
+ try:
64
+ raw = path.read_text(encoding="utf-8")
65
+ except OSError as exc:
66
+ return None, [f"Failed to read USER_REPLY.md: {exc}"]
67
+
68
+ data, body = parse_markdown_frontmatter(raw)
69
+ title = data.get("title")
70
+ title_str = title.strip() if isinstance(title, str) and title.strip() else None
71
+ extra = dict(data)
72
+ extra.pop("title", None)
73
+
74
+ # Keep the body as-is, but normalize leading whitespace so it mirrors DISPATCH.md.
75
+ return UserReply(body=body.lstrip("\n"), title=title_str, extra=extra), []
76
+
77
+
78
+ def _copy_item(src: Path, dst: Path) -> None:
79
+ if src.is_dir():
80
+ shutil.copytree(src, dst)
81
+ else:
82
+ dst.parent.mkdir(parents=True, exist_ok=True)
83
+ shutil.copy2(src, dst)
84
+
85
+
86
+ def _list_reply_items(reply_dir: Path) -> list[Path]:
87
+ if not reply_dir.exists() or not reply_dir.is_dir():
88
+ return []
89
+ items: list[Path] = []
90
+ for child in sorted(reply_dir.iterdir(), key=lambda p: p.name):
91
+ if child.name.startswith("."):
92
+ continue
93
+ items.append(child)
94
+ return items
95
+
96
+
97
+ def _delete_items(items: list[Path]) -> None:
98
+ for item in items:
99
+ try:
100
+ if item.is_dir():
101
+ shutil.rmtree(item)
102
+ else:
103
+ item.unlink()
104
+ except OSError:
105
+ continue
106
+
107
+
108
+ _SEQ_RE = re.compile(r"^[0-9]{4}$")
109
+
110
+
111
+ def next_reply_seq(reply_history_dir: Path) -> int:
112
+ """Return the next sequence number for reply_history."""
113
+
114
+ if not reply_history_dir.exists() or not reply_history_dir.is_dir():
115
+ return 1
116
+ existing: list[int] = []
117
+ for child in reply_history_dir.iterdir():
118
+ try:
119
+ if not child.is_dir():
120
+ continue
121
+ if not _SEQ_RE.fullmatch(child.name):
122
+ continue
123
+ existing.append(int(child.name))
124
+ except OSError:
125
+ continue
126
+ return (max(existing) + 1) if existing else 1
127
+
128
+
129
+ def dispatch_reply(
130
+ paths: ReplyPaths, *, next_seq: int
131
+ ) -> tuple[Optional[ReplyDispatch], list[str]]:
132
+ """Archive USER_REPLY.md + reply/* into reply_history/<seq>/.
133
+
134
+ Returns (dispatch, errors). When USER_REPLY.md does not exist, returns (None, []).
135
+ """
136
+
137
+ if not paths.user_reply_path.exists():
138
+ return None, []
139
+
140
+ reply, errors = parse_user_reply(paths.user_reply_path)
141
+ if errors or reply is None:
142
+ return None, errors
143
+
144
+ items = _list_reply_items(paths.reply_dir)
145
+ dest = paths.reply_history_dir / f"{next_seq:04d}"
146
+ try:
147
+ dest.mkdir(parents=True, exist_ok=False)
148
+ except OSError as exc:
149
+ return None, [f"Failed to create reply history dir: {exc}"]
150
+
151
+ archived: list[Path] = []
152
+ try:
153
+ msg_dest = dest / "USER_REPLY.md"
154
+ _copy_item(paths.user_reply_path, msg_dest)
155
+ archived.append(msg_dest)
156
+
157
+ for item in items:
158
+ item_dest = dest / item.name
159
+ _copy_item(item, item_dest)
160
+ archived.append(item_dest)
161
+ except OSError as exc:
162
+ return None, [f"Failed to archive reply: {exc}"]
163
+
164
+ # Cleanup (best-effort).
165
+ try:
166
+ paths.user_reply_path.unlink()
167
+ except OSError:
168
+ pass
169
+ _delete_items(items)
170
+
171
+ return (
172
+ ReplyDispatch(
173
+ seq=next_seq,
174
+ reply=reply,
175
+ archived_dir=dest,
176
+ archived_files=tuple(archived),
177
+ ),
178
+ [],
179
+ )