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
@@ -0,0 +1,683 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from ..bootstrap import ensure_pma_docs
9
+ from ..tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
10
+ from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
11
+ from .config import load_hub_config, load_repo_config
12
+ from .flows.models import FlowRunStatus
13
+ from .flows.store import FlowStore
14
+ from .hub import HubSupervisor
15
+ from .state_roots import resolve_hub_templates_root
16
+
17
+ PMA_MAX_REPOS = 25
18
+ PMA_MAX_MESSAGES = 10
19
+ PMA_MAX_TEXT = 800
20
+ PMA_MAX_TEMPLATE_REPOS = 25
21
+ PMA_MAX_TEMPLATE_FIELD_CHARS = 120
22
+ PMA_MAX_PMA_FILES = 50
23
+ PMA_MAX_LIFECYCLE_EVENTS = 20
24
+
25
+ # Keep this short and stable; see ticket TICKET-001 for rationale.
26
+ PMA_FASTPATH = """<pma_fastpath>
27
+ You are PMA inside Codex Autorunner (CAR). Treat the filesystem as truth; prefer creating/updating CAR artifacts over "chat-only" plans.
28
+
29
+ First-turn routine:
30
+ 1) Read <user_message> and <hub_snapshot>.
31
+ 2) If hub_snapshot.inbox has entries, handle them first (these are paused runs needing input):
32
+ - Summarize the dispatch question.
33
+ - Answer it or propose the next minimal action.
34
+ - Include the item.open_url so the user can jump straight to the repo Inbox tab.
35
+ 3) If the request is new work:
36
+ - Identify the target repo(s).
37
+ - Prefer hub-owned worktrees for changes.
38
+ - Create/adjust repo tickets under each repo's `.codex-autorunner/tickets/`.
39
+
40
+ Web UI map (user perspective):
41
+ - Hub root: `/` (repos list + global notifications).
42
+ - Repo view: `/repos/<repo_id>/` tabs: Tickets | Inbox | Workspace | Terminal | Analytics | Archive.
43
+ - Tickets: edit queue; Inbox: paused run dispatches; Workspace: active_context/spec/decisions.
44
+ </pma_fastpath>
45
+ """
46
+
47
+ # Defaults used when hub config is not available (should be rare).
48
+ PMA_DOCS_MAX_CHARS = 12_000
49
+ PMA_ACTIVE_CONTEXT_MAX_LINES = 200
50
+ PMA_CONTEXT_LOG_TAIL_LINES = 120
51
+
52
+
53
+ def _tail_lines(text: str, max_lines: int) -> str:
54
+ if max_lines <= 0:
55
+ return ""
56
+ lines = (text or "").splitlines()
57
+ if len(lines) <= max_lines:
58
+ return "\n".join(lines)
59
+ return "\n".join(lines[-max_lines:])
60
+
61
+
62
+ def load_pma_workspace_docs(hub_root: Path) -> dict[str, Any]:
63
+ """Load hub-level PMA workspace docs for prompt injection.
64
+
65
+ These docs act as durable memory and working context for PMA.
66
+ """
67
+ try:
68
+ ensure_pma_docs(hub_root)
69
+ except Exception:
70
+ pass
71
+
72
+ docs_max_chars = PMA_DOCS_MAX_CHARS
73
+ active_context_max_lines = PMA_ACTIVE_CONTEXT_MAX_LINES
74
+ context_log_tail_lines = PMA_CONTEXT_LOG_TAIL_LINES
75
+ try:
76
+ hub_config = load_hub_config(hub_root)
77
+ pma_cfg = getattr(hub_config, "pma", None)
78
+ if pma_cfg is not None:
79
+ docs_max_chars = int(getattr(pma_cfg, "docs_max_chars", docs_max_chars))
80
+ active_context_max_lines = int(
81
+ getattr(pma_cfg, "active_context_max_lines", active_context_max_lines)
82
+ )
83
+ context_log_tail_lines = int(
84
+ getattr(pma_cfg, "context_log_tail_lines", context_log_tail_lines)
85
+ )
86
+ except Exception:
87
+ pass
88
+
89
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
90
+ agents_path = pma_dir / "AGENTS.md"
91
+ active_context_path = pma_dir / "active_context.md"
92
+ context_log_path = pma_dir / "context_log.md"
93
+
94
+ def _read(path: Path) -> str:
95
+ try:
96
+ return path.read_text(encoding="utf-8")
97
+ except Exception:
98
+ return ""
99
+
100
+ agents = _truncate(_read(agents_path), docs_max_chars)
101
+ active_context = _read(active_context_path)
102
+ active_context_lines = len((active_context or "").splitlines())
103
+ active_context = _truncate(active_context, docs_max_chars)
104
+ context_log_tail = _tail_lines(_read(context_log_path), context_log_tail_lines)
105
+ context_log_tail = _truncate(context_log_tail, docs_max_chars)
106
+
107
+ return {
108
+ "agents": agents,
109
+ "active_context": active_context,
110
+ "active_context_line_count": active_context_lines,
111
+ "active_context_max_lines": active_context_max_lines,
112
+ "context_log_tail": context_log_tail,
113
+ }
114
+
115
+
116
+ def _truncate(text: Optional[str], limit: int) -> str:
117
+ raw = text or ""
118
+ if len(raw) <= limit:
119
+ return raw
120
+ return raw[: max(0, limit - 3)] + "..."
121
+
122
+
123
+ def _trim_extra(extra: Any, limit: int) -> Any:
124
+ if extra is None:
125
+ return None
126
+ if isinstance(extra, str):
127
+ return _truncate(extra, limit)
128
+ try:
129
+ raw = json.dumps(extra, ensure_ascii=True, sort_keys=True, default=str)
130
+ except Exception:
131
+ raw = str(extra)
132
+ if len(raw) <= limit:
133
+ return extra
134
+ return {
135
+ "_omitted": True,
136
+ "note": "extra omitted due to size",
137
+ "preview": _truncate(raw, limit),
138
+ }
139
+
140
+
141
+ def _load_template_scan_summary(
142
+ hub_root: Optional[Path],
143
+ *,
144
+ max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
145
+ ) -> Optional[dict[str, Any]]:
146
+ if hub_root is None:
147
+ return None
148
+ try:
149
+ scans_root = resolve_hub_templates_root(hub_root) / "scans"
150
+ if not scans_root.exists():
151
+ return None
152
+ candidates = [
153
+ entry
154
+ for entry in scans_root.iterdir()
155
+ if entry.is_file() and entry.suffix == ".json"
156
+ ]
157
+ if not candidates:
158
+ return None
159
+ newest = max(candidates, key=lambda entry: entry.stat().st_mtime)
160
+ payload = json.loads(newest.read_text(encoding="utf-8"))
161
+ if not isinstance(payload, dict):
162
+ return None
163
+ return {
164
+ "repo_id": _truncate(str(payload.get("repo_id", "")), max_field_chars),
165
+ "decision": _truncate(str(payload.get("decision", "")), max_field_chars),
166
+ "severity": _truncate(str(payload.get("severity", "")), max_field_chars),
167
+ "scanned_at": _truncate(
168
+ str(payload.get("scanned_at", "")), max_field_chars
169
+ ),
170
+ }
171
+ except Exception:
172
+ return None
173
+
174
+
175
+ def _build_templates_snapshot(
176
+ supervisor: HubSupervisor,
177
+ *,
178
+ hub_root: Optional[Path] = None,
179
+ max_repos: int = PMA_MAX_TEMPLATE_REPOS,
180
+ max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
181
+ ) -> dict[str, Any]:
182
+ hub_config = getattr(supervisor, "hub_config", None)
183
+ templates_cfg = getattr(hub_config, "templates", None)
184
+ if templates_cfg is None:
185
+ return {"enabled": False, "repos": []}
186
+ repos = []
187
+ for repo in templates_cfg.repos[: max(0, max_repos)]:
188
+ repos.append(
189
+ {
190
+ "id": _truncate(repo.id, max_field_chars),
191
+ "default_ref": _truncate(repo.default_ref, max_field_chars),
192
+ "trusted": bool(repo.trusted),
193
+ }
194
+ )
195
+ payload: dict[str, Any] = {
196
+ "enabled": bool(templates_cfg.enabled),
197
+ "repos": repos,
198
+ }
199
+ scan_summary = _load_template_scan_summary(
200
+ hub_root, max_field_chars=max_field_chars
201
+ )
202
+ if scan_summary:
203
+ payload["last_scan"] = scan_summary
204
+ return payload
205
+
206
+
207
+ def load_pma_prompt(hub_root: Path) -> str:
208
+ path = hub_root / ".codex-autorunner" / "pma" / "prompt.md"
209
+ try:
210
+ ensure_pma_docs(hub_root)
211
+ except Exception:
212
+ pass
213
+ try:
214
+ return path.read_text(encoding="utf-8")
215
+ except Exception:
216
+ return ""
217
+
218
+
219
+ def _render_ticket_flow_summary(summary: Optional[dict[str, Any]]) -> str:
220
+ if not summary:
221
+ return "null"
222
+ status = summary.get("status")
223
+ done_count = summary.get("done_count")
224
+ total_count = summary.get("total_count")
225
+ current_step = summary.get("current_step")
226
+ parts: list[str] = []
227
+ if status is not None:
228
+ parts.append(f"status={status}")
229
+ if done_count is not None and total_count is not None:
230
+ parts.append(f"done={done_count}/{total_count}")
231
+ if current_step is not None:
232
+ parts.append(f"step={current_step}")
233
+ if not parts:
234
+ return "null"
235
+ return " ".join(parts)
236
+
237
+
238
+ def _render_hub_snapshot(
239
+ snapshot: dict[str, Any],
240
+ *,
241
+ max_repos: int = PMA_MAX_REPOS,
242
+ max_messages: int = PMA_MAX_MESSAGES,
243
+ max_text_chars: int = PMA_MAX_TEXT,
244
+ max_template_repos: int = PMA_MAX_TEMPLATE_REPOS,
245
+ max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
246
+ max_pma_files: int = PMA_MAX_PMA_FILES,
247
+ max_lifecycle_events: int = PMA_MAX_LIFECYCLE_EVENTS,
248
+ ) -> str:
249
+ lines: list[str] = []
250
+
251
+ inbox = snapshot.get("inbox") or []
252
+ if inbox:
253
+ lines.append("Inbox (paused runs needing attention):")
254
+ for item in list(inbox)[: max(0, max_messages)]:
255
+ repo_id = _truncate(str(item.get("repo_id", "")), max_field_chars)
256
+ run_id = _truncate(str(item.get("run_id", "")), max_field_chars)
257
+ seq = _truncate(str(item.get("seq", "")), max_field_chars)
258
+ dispatch = item.get("dispatch") or {}
259
+ mode = _truncate(str(dispatch.get("mode", "")), max_field_chars)
260
+ handoff = bool(dispatch.get("is_handoff"))
261
+ lines.append(
262
+ f"- repo_id={repo_id} run_id={run_id} seq={seq} mode={mode} "
263
+ f"handoff={str(handoff).lower()}"
264
+ )
265
+ title = dispatch.get("title")
266
+ if title:
267
+ lines.append(f" title: {_truncate(str(title), max_text_chars)}")
268
+ body = dispatch.get("body")
269
+ if body:
270
+ lines.append(f" body: {_truncate(str(body), max_text_chars)}")
271
+ files = item.get("files") or []
272
+ if files:
273
+ display = [
274
+ _truncate(str(name), max_field_chars)
275
+ for name in list(files)[: max(0, max_pma_files)]
276
+ ]
277
+ lines.append(f" attachments: [{', '.join(display)}]")
278
+ open_url = item.get("open_url")
279
+ if open_url:
280
+ lines.append(f" open_url: {_truncate(str(open_url), max_field_chars)}")
281
+ lines.append("")
282
+
283
+ repos = snapshot.get("repos") or []
284
+ if repos:
285
+ lines.append("Repos:")
286
+ for repo in list(repos)[: max(0, max_repos)]:
287
+ repo_id = _truncate(str(repo.get("id", "")), max_field_chars)
288
+ display_name = _truncate(str(repo.get("display_name", "")), max_field_chars)
289
+ status = _truncate(str(repo.get("status", "")), max_field_chars)
290
+ last_run_id = _truncate(str(repo.get("last_run_id", "")), max_field_chars)
291
+ last_exit = _truncate(str(repo.get("last_exit_code", "")), max_field_chars)
292
+ ticket_flow = _render_ticket_flow_summary(repo.get("ticket_flow"))
293
+ lines.append(
294
+ f"- {repo_id} ({display_name}): status={status} "
295
+ f"last_run_id={last_run_id} last_exit_code={last_exit} "
296
+ f"ticket_flow={ticket_flow}"
297
+ )
298
+ lines.append("")
299
+
300
+ templates = snapshot.get("templates") or {}
301
+ template_repos = templates.get("repos") or []
302
+ template_scan = templates.get("last_scan")
303
+ if templates.get("enabled") or template_repos or template_scan:
304
+ enabled = bool(templates.get("enabled"))
305
+ lines.append("Templates:")
306
+ lines.append(f"- enabled={str(enabled).lower()}")
307
+ if template_repos:
308
+ items: list[str] = []
309
+ for repo in list(template_repos)[: max(0, max_template_repos)]:
310
+ repo_id = _truncate(str(repo.get("id", "")), max_field_chars)
311
+ default_ref = _truncate(
312
+ str(repo.get("default_ref", "")), max_field_chars
313
+ )
314
+ trusted = bool(repo.get("trusted"))
315
+ items.append(f"{repo_id}@{default_ref} trusted={str(trusted).lower()}")
316
+ lines.append(f"- repos: [{', '.join(items)}]")
317
+ if template_scan:
318
+ repo_id = _truncate(str(template_scan.get("repo_id", "")), max_field_chars)
319
+ decision = _truncate(
320
+ str(template_scan.get("decision", "")), max_field_chars
321
+ )
322
+ severity = _truncate(
323
+ str(template_scan.get("severity", "")), max_field_chars
324
+ )
325
+ scanned_at = _truncate(
326
+ str(template_scan.get("scanned_at", "")), max_field_chars
327
+ )
328
+ lines.append(
329
+ f"- last_scan: {repo_id} {decision} {severity} {scanned_at}".strip()
330
+ )
331
+ lines.append("")
332
+
333
+ pma_files = snapshot.get("pma_files") or {}
334
+ inbox_files = pma_files.get("inbox") or []
335
+ outbox_files = pma_files.get("outbox") or []
336
+ if inbox_files or outbox_files:
337
+ lines.append("PMA files:")
338
+ if inbox_files:
339
+ files = [
340
+ _truncate(str(name), max_field_chars)
341
+ for name in list(inbox_files)[: max(0, max_pma_files)]
342
+ ]
343
+ lines.append(f"- inbox: [{', '.join(files)}]")
344
+ if outbox_files:
345
+ files = [
346
+ _truncate(str(name), max_field_chars)
347
+ for name in list(outbox_files)[: max(0, max_pma_files)]
348
+ ]
349
+ lines.append(f"- outbox: [{', '.join(files)}]")
350
+ lines.append("")
351
+
352
+ lifecycle_events = snapshot.get("lifecycle_events") or []
353
+ if lifecycle_events:
354
+ lines.append("Lifecycle events (recent):")
355
+ for event in list(lifecycle_events)[: max(0, max_lifecycle_events)]:
356
+ timestamp = _truncate(str(event.get("timestamp", "")), max_field_chars)
357
+ event_type = _truncate(str(event.get("event_type", "")), max_field_chars)
358
+ repo_id = _truncate(str(event.get("repo_id", "")), max_field_chars)
359
+ run_id = _truncate(str(event.get("run_id", "")), max_field_chars)
360
+ lines.append(
361
+ f"- {timestamp} {event_type} repo_id={repo_id} run_id={run_id}"
362
+ )
363
+ lines.append("")
364
+
365
+ if lines and lines[-1] == "":
366
+ lines.pop()
367
+ return "\n".join(lines)
368
+
369
+
370
+ def format_pma_prompt(
371
+ base_prompt: str,
372
+ snapshot: dict[str, Any],
373
+ message: str,
374
+ hub_root: Optional[Path] = None,
375
+ ) -> str:
376
+ limits = snapshot.get("limits") or {}
377
+ snapshot_text = _render_hub_snapshot(
378
+ snapshot,
379
+ max_repos=limits.get("max_repos", PMA_MAX_REPOS),
380
+ max_messages=limits.get("max_messages", PMA_MAX_MESSAGES),
381
+ max_text_chars=limits.get("max_text_chars", PMA_MAX_TEXT),
382
+ )
383
+
384
+ pma_docs: Optional[dict[str, Any]] = None
385
+ if hub_root is not None:
386
+ try:
387
+ pma_docs = load_pma_workspace_docs(hub_root)
388
+ except Exception:
389
+ pma_docs = None
390
+
391
+ prompt = f"{base_prompt}\n\n"
392
+ prompt += (
393
+ "Ops guide: `.codex-autorunner/pma/ABOUT_CAR.md`.\n"
394
+ "Durable guidance: `.codex-autorunner/pma/AGENTS.md`.\n"
395
+ "Working context: `.codex-autorunner/pma/active_context.md`.\n"
396
+ "History: `.codex-autorunner/pma/context_log.md`.\n"
397
+ "To send a file to the user, write it to `.codex-autorunner/pma/outbox/`.\n"
398
+ "User uploaded files are in `.codex-autorunner/pma/inbox/`.\n\n"
399
+ )
400
+
401
+ if pma_docs:
402
+ max_lines = pma_docs.get("active_context_max_lines")
403
+ line_count = pma_docs.get("active_context_line_count")
404
+ prompt += (
405
+ "<pma_workspace_docs>\n"
406
+ "<AGENTS_MD>\n"
407
+ f"{pma_docs.get('agents', '')}\n"
408
+ "</AGENTS_MD>\n"
409
+ "<ACTIVE_CONTEXT_MD>\n"
410
+ f"{pma_docs.get('active_context', '')}\n"
411
+ "</ACTIVE_CONTEXT_MD>\n"
412
+ f"<ACTIVE_CONTEXT_BUDGET lines='{max_lines}' current_lines='{line_count}' />\n"
413
+ "<CONTEXT_LOG_TAIL_MD>\n"
414
+ f"{pma_docs.get('context_log_tail', '')}\n"
415
+ "</CONTEXT_LOG_TAIL_MD>\n"
416
+ "</pma_workspace_docs>\n\n"
417
+ )
418
+
419
+ prompt += f"{PMA_FASTPATH}\n\n"
420
+ prompt += (
421
+ "<hub_snapshot>\n"
422
+ f"{snapshot_text}\n"
423
+ "</hub_snapshot>\n\n"
424
+ "<user_message>\n"
425
+ f"{message}\n"
426
+ "</user_message>\n"
427
+ )
428
+ return prompt
429
+
430
+
431
+ def _get_ticket_flow_summary(repo_path: Path) -> Optional[dict[str, Any]]:
432
+ db_path = repo_path / ".codex-autorunner" / "flows.db"
433
+ if not db_path.exists():
434
+ return None
435
+ try:
436
+ config = load_repo_config(repo_path)
437
+ with FlowStore(db_path, durable=config.durable_writes) as store:
438
+ runs = store.list_flow_runs(flow_type="ticket_flow")
439
+ if not runs:
440
+ return None
441
+ latest = runs[0]
442
+
443
+ ticket_dir = repo_path / ".codex-autorunner" / "tickets"
444
+ total = 0
445
+ done = 0
446
+ for path in list_ticket_paths(ticket_dir):
447
+ total += 1
448
+ try:
449
+ if ticket_is_done(path):
450
+ done += 1
451
+ except Exception:
452
+ continue
453
+
454
+ if total == 0:
455
+ return None
456
+
457
+ state = latest.state if isinstance(latest.state, dict) else {}
458
+ engine = state.get("ticket_engine") if isinstance(state, dict) else {}
459
+ engine = engine if isinstance(engine, dict) else {}
460
+ current_step = engine.get("total_turns")
461
+
462
+ return {
463
+ "status": latest.status.value,
464
+ "done_count": done,
465
+ "total_count": total,
466
+ "current_step": current_step,
467
+ }
468
+ except Exception:
469
+ return None
470
+
471
+
472
+ def _latest_dispatch(
473
+ repo_root: Path, run_id: str, input_data: dict, *, max_text_chars: int
474
+ ) -> Optional[dict[str, Any]]:
475
+ try:
476
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
477
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
478
+ outbox_paths = resolve_outbox_paths(
479
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
480
+ )
481
+ history_dir = outbox_paths.dispatch_history_dir
482
+ if not history_dir.exists() or not history_dir.is_dir():
483
+ return None
484
+ seq_dirs: list[Path] = []
485
+ for child in history_dir.iterdir():
486
+ if not child.is_dir():
487
+ continue
488
+ name = child.name
489
+ if len(name) == 4 and name.isdigit():
490
+ seq_dirs.append(child)
491
+ if not seq_dirs:
492
+ return None
493
+ latest_dir = sorted(seq_dirs, key=lambda p: p.name)[-1]
494
+ seq = int(latest_dir.name)
495
+ dispatch_path = latest_dir / "DISPATCH.md"
496
+ dispatch, errors = parse_dispatch(dispatch_path)
497
+ if errors or dispatch is None:
498
+ return {
499
+ "seq": seq,
500
+ "dir": safe_relpath(latest_dir, repo_root),
501
+ "dispatch": None,
502
+ "errors": errors,
503
+ "files": [],
504
+ }
505
+ files: list[str] = []
506
+ for child in sorted(latest_dir.iterdir(), key=lambda p: p.name):
507
+ if child.name.startswith("."):
508
+ continue
509
+ if child.name == "DISPATCH.md":
510
+ continue
511
+ if child.is_file():
512
+ files.append(child.name)
513
+ dispatch_dict = {
514
+ "mode": dispatch.mode,
515
+ "title": _truncate(dispatch.title, max_text_chars),
516
+ "body": _truncate(dispatch.body, max_text_chars),
517
+ "extra": _trim_extra(dispatch.extra, max_text_chars),
518
+ "is_handoff": dispatch.is_handoff,
519
+ }
520
+ return {
521
+ "seq": seq,
522
+ "dir": safe_relpath(latest_dir, repo_root),
523
+ "dispatch": dispatch_dict,
524
+ "errors": [],
525
+ "files": files,
526
+ }
527
+ except Exception:
528
+ return None
529
+
530
+
531
+ def _gather_inbox(
532
+ supervisor: HubSupervisor, *, max_text_chars: int
533
+ ) -> list[dict[str, Any]]:
534
+ messages: list[dict[str, Any]] = []
535
+ try:
536
+ snapshots = supervisor.list_repos()
537
+ except Exception:
538
+ return []
539
+ for snap in snapshots:
540
+ if not (snap.initialized and snap.exists_on_disk):
541
+ continue
542
+ repo_root = snap.path
543
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
544
+ if not db_path.exists():
545
+ continue
546
+ try:
547
+ config = load_repo_config(repo_root)
548
+ with FlowStore(db_path, durable=config.durable_writes) as store:
549
+ paused = store.list_flow_runs(
550
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
551
+ )
552
+ except Exception:
553
+ continue
554
+ if not paused:
555
+ continue
556
+ for record in paused:
557
+ latest = _latest_dispatch(
558
+ repo_root,
559
+ str(record.id),
560
+ dict(record.input_data or {}),
561
+ max_text_chars=max_text_chars,
562
+ )
563
+ if not latest or not latest.get("dispatch"):
564
+ continue
565
+ messages.append(
566
+ {
567
+ "repo_id": snap.id,
568
+ "repo_display_name": snap.display_name,
569
+ "run_id": record.id,
570
+ "run_created_at": record.created_at,
571
+ "seq": latest["seq"],
572
+ "dispatch": latest["dispatch"],
573
+ "files": latest.get("files") or [],
574
+ "open_url": f"/repos/{snap.id}/?tab=inbox&run_id={record.id}",
575
+ }
576
+ )
577
+ messages.sort(key=lambda m: (m.get("run_created_at") or ""), reverse=True)
578
+ return messages
579
+
580
+
581
+ def _gather_lifecycle_events(
582
+ supervisor: HubSupervisor, limit: int = 20
583
+ ) -> list[dict[str, Any]]:
584
+ events = supervisor.lifecycle_store.get_unprocessed(limit=limit)
585
+ result: list[dict[str, Any]] = []
586
+ for event in events[:limit]:
587
+ result.append(
588
+ {
589
+ "event_type": event.event_type.value,
590
+ "repo_id": event.repo_id,
591
+ "run_id": event.run_id,
592
+ "timestamp": event.timestamp,
593
+ "data": event.data,
594
+ }
595
+ )
596
+ return result
597
+
598
+
599
+ async def build_hub_snapshot(
600
+ supervisor: Optional[HubSupervisor],
601
+ hub_root: Optional[Path] = None,
602
+ ) -> dict[str, Any]:
603
+ if supervisor is None:
604
+ return {
605
+ "repos": [],
606
+ "inbox": [],
607
+ "templates": {"enabled": False, "repos": []},
608
+ "lifecycle_events": [],
609
+ }
610
+
611
+ snapshots = await asyncio.to_thread(supervisor.list_repos)
612
+ snapshots = sorted(snapshots, key=lambda snap: snap.id)
613
+ pma_config = supervisor.hub_config.pma if supervisor else None
614
+ max_repos = (
615
+ pma_config.max_repos
616
+ if pma_config and pma_config.max_repos > 0
617
+ else PMA_MAX_REPOS
618
+ )
619
+ max_messages = (
620
+ pma_config.max_messages
621
+ if pma_config and pma_config.max_messages > 0
622
+ else PMA_MAX_MESSAGES
623
+ )
624
+ max_text_chars = (
625
+ pma_config.max_text_chars
626
+ if pma_config and pma_config.max_text_chars > 0
627
+ else PMA_MAX_TEXT
628
+ )
629
+ repos: list[dict[str, Any]] = []
630
+ for snap in snapshots[:max_repos]:
631
+ summary: dict[str, Any] = {
632
+ "id": snap.id,
633
+ "display_name": snap.display_name,
634
+ "status": snap.status.value,
635
+ "last_run_id": snap.last_run_id,
636
+ "last_run_started_at": snap.last_run_started_at,
637
+ "last_run_finished_at": snap.last_run_finished_at,
638
+ "last_exit_code": snap.last_exit_code,
639
+ "ticket_flow": None,
640
+ }
641
+ if snap.initialized and snap.exists_on_disk:
642
+ summary["ticket_flow"] = _get_ticket_flow_summary(snap.path)
643
+ repos.append(summary)
644
+
645
+ inbox = await asyncio.to_thread(
646
+ _gather_inbox, supervisor, max_text_chars=max_text_chars
647
+ )
648
+ inbox = inbox[:max_messages]
649
+
650
+ lifecycle_events = await asyncio.to_thread(
651
+ _gather_lifecycle_events, supervisor, limit=20
652
+ )
653
+
654
+ templates = _build_templates_snapshot(supervisor, hub_root=hub_root)
655
+
656
+ pma_files: dict[str, list[str]] = {"inbox": [], "outbox": []}
657
+ if hub_root:
658
+ try:
659
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
660
+ for box in ["inbox", "outbox"]:
661
+ box_dir = pma_dir / box
662
+ if box_dir.exists():
663
+ files = [
664
+ f.name
665
+ for f in box_dir.iterdir()
666
+ if f.is_file() and not f.name.startswith(".")
667
+ ]
668
+ pma_files[box] = sorted(files)
669
+ except Exception:
670
+ pass
671
+
672
+ return {
673
+ "repos": repos,
674
+ "inbox": inbox,
675
+ "templates": templates,
676
+ "pma_files": pma_files,
677
+ "lifecycle_events": lifecycle_events,
678
+ "limits": {
679
+ "max_repos": max_repos,
680
+ "max_messages": max_messages,
681
+ "max_text_chars": max_text_chars,
682
+ },
683
+ }