codex-autorunner 1.2.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.
@@ -7,7 +7,6 @@ from .config import (
7
7
  REPO_OVERRIDE_FILENAME,
8
8
  ROOT_CONFIG_FILENAME,
9
9
  ROOT_OVERRIDE_FILENAME,
10
- Config,
11
10
  find_nearest_hub_config_path,
12
11
  )
13
12
 
@@ -98,6 +97,18 @@ def build_about_car_markdown(
98
97
  f"`{decisions_disp}`\n"
99
98
  "- **Spec**: "
100
99
  f"`{spec_disp}`\n\n"
100
+ "## Web UI quick map (repo page)\n"
101
+ "- Repo view: `/repos/<repo_id>/`\n"
102
+ "- Tabs: **Tickets** = `.codex-autorunner/tickets/` queue.\n"
103
+ "- Tabs: **Inbox** = paused run dispatches/handoffs.\n"
104
+ "- Tabs: **Workspace** = edit `active_context.md`, `spec.md`, `decisions.md`.\n"
105
+ "- Tabs: **Terminal** = launches the configured `codex` binary in a PTY.\n"
106
+ "- Tabs: **Archive** = browse worktree snapshots.\n\n"
107
+ "## FileBox (attachments)\n"
108
+ "- Repo FileBox root: `.codex-autorunner/filebox/`.\n"
109
+ "- User uploads: `.codex-autorunner/filebox/inbox/`.\n"
110
+ "- Files to send back: `.codex-autorunner/filebox/outbox/`.\n"
111
+ "- Note: ticket_flow uses per-run dispatch directories; do not confuse dispatch with FileBox.\n\n"
101
112
  "## Critical rules\n"
102
113
  "- Do **not** create new copies of workspace docs elsewhere in the repo.\n"
103
114
  "- Treat `.codex-autorunner/` as intentional project structure even though it is hidden/gitignored.\n\n"
@@ -211,17 +222,6 @@ def build_tickets_agents_markdown(*, repo_root: Path) -> str:
211
222
  )
212
223
 
213
224
 
214
- def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
215
- """Config-aware wrapper that uses configured doc paths."""
216
- repo_root = config.root
217
- docs = {
218
- "active_context": config.doc_path("active_context"),
219
- "decisions": config.doc_path("decisions"),
220
- "spec": config.doc_path("spec"),
221
- }
222
- return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
223
-
224
-
225
225
  def ensure_ticket_flow_quickstart_file_for_repo(
226
226
  repo_root: Path, *, force: bool = False
227
227
  ) -> Path:
@@ -258,7 +258,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
258
258
  "files": True,
259
259
  "max_image_bytes": 10_000_000,
260
260
  "max_voice_bytes": 10_000_000,
261
- "max_file_bytes": 10_000_000,
261
+ "max_file_bytes": 100_000_000,
262
262
  "image_prompt": (
263
263
  "The user sent an image with no caption. Use it to continue the "
264
264
  "conversation; if no clear task, describe the image and ask what "
@@ -542,7 +542,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
542
542
  "files": True,
543
543
  "max_image_bytes": 10_000_000,
544
544
  "max_voice_bytes": 10_000_000,
545
- "max_file_bytes": 10_000_000,
545
+ "max_file_bytes": 100_000_000,
546
546
  "image_prompt": (
547
547
  "The user sent an image with no caption. Use it to continue the "
548
548
  "conversation; if no clear task, describe the image and ask what "
@@ -12,6 +12,7 @@ CAR’s durable control-plane lives under `.codex-autorunner/`:
12
12
  - `active_context.md` — current “north star” context; kept fresh for ongoing work.
13
13
  - `spec.md` — longer spec / acceptance criteria when needed.
14
14
  - `decisions.md` — prior decisions / tradeoffs when relevant.
15
+ - `.codex-autorunner/filebox/` — attachments inbox/outbox used by CAR surfaces (if present).
15
16
 
16
17
  Intent signals: if the user mentions tickets, “dispatch”, “resume”, workspace docs, or `.codex-autorunner/`, they are likely referring to CAR artifacts/workflow rather than generic repo files.
17
18
 
@@ -19,6 +19,30 @@ PMA_MAX_MESSAGES = 10
19
19
  PMA_MAX_TEXT = 800
20
20
  PMA_MAX_TEMPLATE_REPOS = 25
21
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
+ """
22
46
 
23
47
  # Defaults used when hub config is not available (should be rare).
24
48
  PMA_DOCS_MAX_CHARS = 12_000
@@ -192,13 +216,170 @@ def load_pma_prompt(hub_root: Path) -> str:
192
216
  return ""
193
217
 
194
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
+
195
370
  def format_pma_prompt(
196
371
  base_prompt: str,
197
372
  snapshot: dict[str, Any],
198
373
  message: str,
199
374
  hub_root: Optional[Path] = None,
200
375
  ) -> str:
201
- snapshot_text = json.dumps(snapshot, sort_keys=True)
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
+ )
202
383
 
203
384
  pma_docs: Optional[dict[str, Any]] = None
204
385
  if hub_root is not None:
@@ -235,6 +416,7 @@ def format_pma_prompt(
235
416
  "</pma_workspace_docs>\n\n"
236
417
  )
237
418
 
419
+ prompt += f"{PMA_FASTPATH}\n\n"
238
420
  prompt += (
239
421
  "<hub_snapshot>\n"
240
422
  f"{snapshot_text}\n"
@@ -493,4 +675,9 @@ async def build_hub_snapshot(
493
675
  "templates": templates,
494
676
  "pma_files": pma_files,
495
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
+ },
496
683
  }
@@ -1370,7 +1370,7 @@ class TelegramBotClient:
1370
1370
  return bool(result) if isinstance(result, bool) else False
1371
1371
 
1372
1372
  async def download_file(
1373
- self, file_path: str, max_size_bytes: int = 50 * 1024 * 1024
1373
+ self, file_path: str, max_size_bytes: int = 100 * 1024 * 1024
1374
1374
  ) -> bytes:
1375
1375
  safe_path = file_path.lstrip("/")
1376
1376
  url = f"{self._file_base_url}/{safe_path}"
@@ -44,7 +44,7 @@ DEFAULT_APP_SERVER_TURN_TIMEOUT_SECONDS = 28800
44
44
  DEFAULT_APPROVAL_TIMEOUT_SECONDS = 300.0
45
45
  DEFAULT_MEDIA_MAX_IMAGE_BYTES = 10 * 1024 * 1024
46
46
  DEFAULT_MEDIA_MAX_VOICE_BYTES = 10 * 1024 * 1024
47
- DEFAULT_MEDIA_MAX_FILE_BYTES = 10 * 1024 * 1024
47
+ DEFAULT_MEDIA_MAX_FILE_BYTES = 100 * 1024 * 1024
48
48
  DEFAULT_MEDIA_IMAGE_PROMPT = (
49
49
  "The user sent an image with no caption. Use it to continue the "
50
50
  "conversation; if no clear task, describe the image and ask what they want."
@@ -859,7 +859,10 @@ async def handle_media_message(
859
859
  best = photos[0]
860
860
  try:
861
861
  file_info = await handlers._bot.get_file(best.file_id)
862
- data = await handlers._bot.download_file(file_info.file_path)
862
+ data = await handlers._bot.download_file(
863
+ file_info.file_path,
864
+ max_size_bytes=handlers._config.media.max_image_bytes,
865
+ )
863
866
  filename = f"photo_{best.file_id}.jpg"
864
867
  files.append((filename, data))
865
868
  except Exception as exc:
@@ -868,7 +871,10 @@ async def handle_media_message(
868
871
  elif message.document:
869
872
  try:
870
873
  file_info = await handlers._bot.get_file(message.document.file_id)
871
- data = await handlers._bot.download_file(file_info.file_path)
874
+ data = await handlers._bot.download_file(
875
+ file_info.file_path,
876
+ max_size_bytes=handlers._config.media.max_file_bytes,
877
+ )
872
878
  filename = (
873
879
  message.document.file_name or f"document_{message.document.file_id}"
874
880
  )